jskim-node #394

Merged
kjs merged 18 commits from jskim-node into main 2026-02-26 13:48:08 +09:00
71 changed files with 8711 additions and 1738 deletions

5
.gitignore vendored
View File

@ -291,5 +291,10 @@ uploads/
claude.md claude.md
# AI 에이전트 테스트 산출물
*-test-screenshots/
*-screenshots/
*-test.mjs
# 개인 작업 문서 (popdocs) # 개인 작업 문서 (popdocs)
popdocs/ popdocs/

View File

@ -105,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성 import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
@ -289,6 +290,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성 app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리

View File

@ -0,0 +1,148 @@
/**
* BOM /
*/
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import * as bomService from "../services/bomService";
// ─── 이력 (History) ─────────────────────────────
export async function getBomHistory(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomHistory(bomId, companyCode, tableName);
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 이력 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function addBomHistory(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const changedBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { change_type, change_description, revision, version, tableName } = req.body;
if (!change_type) {
res.status(400).json({ success: false, message: "change_type은 필수입니다" });
return;
}
const result = await bomService.addBomHistory(bomId, companyCode, {
change_type,
change_description,
revision,
version,
changed_by: changedBy,
}, tableName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 이력 등록 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────
export async function getBomHeader(req: Request, res: Response) {
try {
const { bomId } = req.params;
const tableName = (req.query.tableName as string) || undefined;
const data = await bomService.getBomHeader(bomId, tableName);
if (!data) {
res.status(404).json({ success: false, message: "BOM을 찾을 수 없습니다" });
return;
}
res.json({ success: true, data });
} catch (error: any) {
logger.error("BOM 헤더 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// ─── 버전 (Version) ─────────────────────────────
export async function getBomVersions(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const tableName = (req.query.tableName as string) || undefined;
const result = await bomService.getBomVersions(bomId, companyCode, tableName);
res.json({
success: true,
data: result.versions,
currentVersionId: result.currentVersionId,
});
} catch (error: any) {
logger.error("BOM 버전 목록 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function createBomVersion(req: Request, res: Response) {
try {
const { bomId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
const { tableName, detailTable } = req.body || {};
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 생성 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function loadBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const companyCode = (req as any).user?.companyCode || "*";
const { tableName, detailTable } = req.body || {};
const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 불러오기 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function activateBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const { tableName } = req.body || {};
const result = await bomService.activateBomVersion(bomId, versionId, tableName);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("BOM 버전 사용 확정 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteBomVersion(req: Request, res: Response) {
try {
const { bomId, versionId } = req.params;
const tableName = (req.query.tableName as string) || undefined;
const detailTable = (req.query.detailTable as string) || undefined;
const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName, detailTable);
if (!deleted) {
res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
return;
}
res.json({ success: true });
} catch (error: any) {
logger.error("BOM 버전 삭제 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -115,7 +115,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
try { try {
const { tableName } = req.params; const { tableName } = req.params;
const { value = "id", label = "name" } = req.query; const { value = "id", label = "name", fields } = req.query;
// tableName 유효성 검증 // tableName 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") { if (!tableName || tableName === "undefined" || tableName === "null") {
@ -167,9 +167,21 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
: ""; : "";
// autoFill용 추가 컬럼 처리
let extraColumns = "";
if (fields && typeof fields === "string") {
const requestedFields = fields.split(",").map((f) => f.trim()).filter(Boolean);
const validExtra = requestedFields.filter(
(f) => existingColumns.has(f) && f !== valueColumn && f !== effectiveLabelColumn
);
if (validExtra.length > 0) {
extraColumns = ", " + validExtra.map((f) => `"${f}"`).join(", ");
}
}
// 쿼리 실행 (최대 500개) // 쿼리 실행 (최대 500개)
const query = ` const query = `
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns}
FROM ${tableName} FROM ${tableName}
${whereClause} ${whereClause}
ORDER BY ${effectiveLabelColumn} ASC ORDER BY ${effectiveLabelColumn} ASC
@ -184,6 +196,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
labelColumn: effectiveLabelColumn, labelColumn: effectiveLabelColumn,
companyCode, companyCode,
rowCount: result.rowCount, rowCount: result.rowCount,
extraFields: extraColumns ? true : false,
}); });
res.json({ res.json({

View File

@ -39,16 +39,18 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon
if (search) params.push(`%${search}%`); if (search) params.push(`%${search}%`);
const query = ` const query = `
SELECT DISTINCT SELECT
i.id, i.id,
i.${nameColumn} AS item_name, i.${nameColumn} AS item_name,
i.${codeColumn} AS item_code i.${codeColumn} AS item_code,
COUNT(rv.id) AS routing_count
FROM ${tableName} i FROM ${tableName} i
INNER JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
AND rv.company_code = i.company_code AND rv.company_code = i.company_code
WHERE i.company_code = $1 WHERE i.company_code = $1
${searchCondition} ${searchCondition}
ORDER BY i.${codeColumn} GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
ORDER BY i.created_date DESC NULLS LAST
`; `;
const result = await getPool().query(query, params); const result = await getPool().query(query, params);
@ -82,10 +84,10 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R
// 라우팅 버전 목록 // 라우팅 버전 목록
const versionsQuery = ` const versionsQuery = `
SELECT id, version_name, description, created_date SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
FROM ${routingVersionTable} FROM ${routingVersionTable}
WHERE ${routingFkColumn} = $1 AND company_code = $2 WHERE ${routingFkColumn} = $1 AND company_code = $2
ORDER BY created_date DESC ORDER BY is_default DESC, created_date DESC
`; `;
const versionsResult = await getPool().query(versionsQuery, [ const versionsResult = await getPool().query(versionsQuery, [
itemCode, itemCode,
@ -127,6 +129,92 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R
} }
} }
// ============================================================
// 기본 버전 설정
// ============================================================
/**
*
*
*/
export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { versionId } = req.params;
const {
routingVersionTable = "item_routing_version",
routingFkColumn = "item_code",
} = req.body;
await client.query("BEGIN");
const versionResult = await client.query(
`SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
if (versionResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
}
const itemCode = versionResult.rows[0].item_code;
await client.query(
`UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`,
[itemCode, companyCode]
);
await client.query(
`UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
await client.query("COMMIT");
logger.info("기본 버전 설정", { companyCode, versionId, itemCode });
return res.json({ success: true, message: "기본 버전이 설정되었습니다" });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("기본 버전 설정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
/**
*
*/
export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
return res.status(401).json({ success: false, message: "인증 필요" });
}
const { versionId } = req.params;
const { routingVersionTable = "item_routing_version" } = req.body;
await getPool().query(
`UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`,
[versionId, companyCode]
);
logger.info("기본 버전 해제", { companyCode, versionId });
return res.json({ success: true, message: "기본 버전이 해제되었습니다" });
} catch (error: any) {
logger.error("기본 버전 해제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ============================================================ // ============================================================
// 작업 항목 CRUD // 작업 항목 CRUD
// ============================================================ // ============================================================
@ -330,7 +418,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons
const { workItemId } = req.params; const { workItemId } = req.params;
const query = ` const query = `
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, created_date SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
created_date
FROM process_work_item_detail FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2 WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order, created_date ORDER BY sort_order, created_date
@ -355,7 +446,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
return res.status(401).json({ success: false, message: "인증 필요" }); return res.status(401).json({ success: false, message: "인증 필요" });
} }
const { work_item_id, detail_type, content, is_required, sort_order, remark } = req.body; const {
work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
} = req.body;
if (!work_item_id || !content) { if (!work_item_id || !content) {
return res.status(400).json({ return res.status(400).json({
@ -375,8 +470,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
const query = ` const query = `
INSERT INTO process_work_item_detail INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING * RETURNING *
`; `;
@ -389,6 +486,15 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
sort_order || 0, sort_order || 0,
remark || null, remark || null,
writer, writer,
inspection_code || null,
inspection_method || null,
unit || null,
lower_limit || null,
upper_limit || null,
duration_minutes || null,
input_type || null,
lookup_target || null,
display_fields || null,
]); ]);
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id }); logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
@ -410,7 +516,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
} }
const { id } = req.params; const { id } = req.params;
const { detail_type, content, is_required, sort_order, remark } = req.body; const {
detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
} = req.body;
const query = ` const query = `
UPDATE process_work_item_detail UPDATE process_work_item_detail
@ -419,6 +529,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
is_required = COALESCE($3, is_required), is_required = COALESCE($3, is_required),
sort_order = COALESCE($4, sort_order), sort_order = COALESCE($4, sort_order),
remark = COALESCE($5, remark), remark = COALESCE($5, remark),
inspection_code = $8,
inspection_method = $9,
unit = $10,
lower_limit = $11,
upper_limit = $12,
duration_minutes = $13,
input_type = $14,
lookup_target = $15,
display_fields = $16,
updated_date = NOW() updated_date = NOW()
WHERE id = $6 AND company_code = $7 WHERE id = $6 AND company_code = $7
RETURNING * RETURNING *
@ -432,6 +551,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
remark, remark,
id, id,
companyCode, companyCode,
inspection_code || null,
inspection_method || null,
unit || null,
lower_limit || null,
upper_limit || null,
duration_minutes || null,
input_type || null,
lookup_target || null,
display_fields || null,
]); ]);
if (result.rowCount === 0) { if (result.rowCount === 0) {
@ -544,8 +672,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
for (const detail of item.details) { for (const detail of item.details) {
await client.query( await client.query(
`INSERT INTO process_work_item_detail `INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[ [
companyCode, companyCode,
workItemId, workItemId,
@ -555,6 +685,15 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
detail.sort_order || 0, detail.sort_order || 0,
detail.remark || null, detail.remark || null,
writer, writer,
detail.inspection_code || null,
detail.inspection_method || null,
detail.unit || null,
detail.lower_limit || null,
detail.upper_limit || null,
detail.duration_minutes || null,
detail.input_type || null,
detail.lookup_target || null,
detail.display_fields || null,
] ]
); );
} }

View File

@ -921,14 +921,51 @@ export async function addTableData(
} }
} }
// 회사별 NOT NULL 소프트 제약조건 검증
const notNullViolations = await tableManagementService.validateNotNullConstraints(
tableName,
data,
companyCode || "*"
);
if (notNullViolations.length > 0) {
res.status(400).json({
success: false,
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
error: {
code: "NOT_NULL_VIOLATION",
details: notNullViolations,
},
});
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
data,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 추가 // 데이터 추가
await tableManagementService.addTableData(tableName, data); const result = await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`); logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
const response: ApiResponse<null> = { const response: ApiResponse<{ id: string | null }> = {
success: true, success: true,
message: "테이블 데이터를 성공적으로 추가했습니다.", message: "테이블 데이터를 성공적으로 추가했습니다.",
data: { id: result.insertedId },
}; };
res.status(201).json(response); res.status(201).json(response);
@ -1003,6 +1040,45 @@ export async function editTableData(
} }
const tableManagementService = new TableManagementService(); const tableManagementService = new TableManagementService();
const companyCode = req.user?.companyCode || "*";
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
const notNullViolations = await tableManagementService.validateNotNullConstraints(
tableName,
updatedData,
companyCode
);
if (notNullViolations.length > 0) {
res.status(400).json({
success: false,
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
error: {
code: "NOT_NULL_VIOLATION",
details: notNullViolations,
},
});
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
const excludeId = originalData?.id ? String(originalData.id) : undefined;
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
updatedData,
companyCode,
excludeId
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 수정 // 데이터 수정
await tableManagementService.editTableData( await tableManagementService.editTableData(
@ -1693,6 +1769,7 @@ export async function getCategoryColumnsByCompany(
let columnsResult; let columnsResult;
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회 // 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
if (companyCode === "*") { if (companyCode === "*") {
const columnsQuery = ` const columnsQuery = `
SELECT DISTINCT SELECT DISTINCT
@ -1712,15 +1789,15 @@ export async function getCategoryColumnsByCompany(
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
AND ttc.company_code = '*' AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name ORDER BY ttc.table_name, ttc.column_name
`; `;
columnsResult = await pool.query(columnsQuery); columnsResult = await pool.query(columnsQuery);
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료", { logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", {
rowCount: columnsResult.rows.length rowCount: columnsResult.rows.length
}); });
} else { } else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = ` const columnsQuery = `
SELECT DISTINCT SELECT DISTINCT
ttc.table_name AS "tableName", ttc.table_name AS "tableName",
@ -1739,11 +1816,12 @@ export async function getCategoryColumnsByCompany(
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
AND ttc.company_code = $1 AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name ORDER BY ttc.table_name, ttc.column_name
`; `;
columnsResult = await pool.query(columnsQuery, [companyCode]); columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 카테고리 컬럼 조회 완료", { logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", {
companyCode, companyCode,
rowCount: columnsResult.rows.length rowCount: columnsResult.rows.length
}); });
@ -1804,13 +1882,10 @@ export async function getCategoryColumnsByMenu(
const { getPool } = await import("../database/db"); const { getPool } = await import("../database/db");
const pool = getPool(); const pool = getPool();
// 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회 // table_type_columns에서 input_type = 'category' 컬럼 조회
// category_column_mapping 대신 table_type_columns 기준으로 조회 // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
let columnsResult; let columnsResult;
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
if (companyCode === "*") { if (companyCode === "*") {
const columnsQuery = ` const columnsQuery = `
SELECT DISTINCT SELECT DISTINCT
@ -1830,15 +1905,15 @@ export async function getCategoryColumnsByMenu(
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
AND ttc.company_code = '*' AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name ORDER BY ttc.table_name, ttc.column_name
`; `;
columnsResult = await pool.query(columnsQuery); columnsResult = await pool.query(columnsQuery);
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", { logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", {
rowCount: columnsResult.rows.length rowCount: columnsResult.rows.length
}); });
} else { } else {
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
const columnsQuery = ` const columnsQuery = `
SELECT DISTINCT SELECT DISTINCT
ttc.table_name AS "tableName", ttc.table_name AS "tableName",
@ -1857,11 +1932,12 @@ export async function getCategoryColumnsByMenu(
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
AND ttc.company_code = $1 AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name ORDER BY ttc.table_name, ttc.column_name
`; `;
columnsResult = await pool.query(columnsQuery, [companyCode]); columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 카테고리 컬럼 조회 완료", { logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", {
companyCode, companyCode,
rowCount: columnsResult.rows.length rowCount: columnsResult.rows.length
}); });
@ -2616,8 +2692,22 @@ export async function toggleTableIndex(
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
if (action === "create") { if (action === "create") {
let indexColumns = `"${columnName}"`;
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
if (indexType === "unique") {
const hasCompanyCode = await query(
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
indexColumns = `"company_code", "${columnName}"`;
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
}
}
const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
logger.info(`인덱스 생성: ${sql}`); logger.info(`인덱스 생성: ${sql}`);
await query(sql); await query(sql);
} else if (action === "drop") { } else if (action === "drop") {
@ -2638,22 +2728,55 @@ export async function toggleTableIndex(
} catch (error: any) { } catch (error: any) {
logger.error("인덱스 토글 오류:", error); logger.error("인덱스 토글 오류:", error);
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 const errMsg = error.message || "";
const errorMsg = error.message?.includes("duplicate key") let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." let duplicates: any[] = [];
: "인덱스 설정 중 오류가 발생했습니다.";
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
if (
errMsg.includes("could not create unique index") ||
errMsg.includes("duplicate key")
) {
const { columnName, tableName } = { ...req.params, ...req.body };
try {
duplicates = await query(
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch {
try {
duplicates = await query(
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch { /* 중복 조회 실패 시 무시 */ }
}
const dupDetails = duplicates.length > 0
? duplicates.map((d: any) => {
const company = d.company_code ? `[${d.company_code}] ` : "";
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
}).join(", ")
: "";
userMessage = dupDetails
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
}
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: errorMsg, message: userMessage,
error: error instanceof Error ? error.message : "Unknown error", error: errMsg,
duplicates,
}); });
} }
} }
/** /**
* NOT NULL * NOT NULL ( )
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
*
* DB ALTER TABLE table_type_columns.is_nullable을 .
* A는 NOT NULL, B는 NULL .
*/ */
export async function toggleColumnNullable( export async function toggleColumnNullable(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -2662,6 +2785,7 @@ export async function toggleColumnNullable(
try { try {
const { tableName, columnName } = req.params; const { tableName, columnName } = req.params;
const { nullable } = req.body; const { nullable } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof nullable !== "boolean") { if (!tableName || !columnName || typeof nullable !== "boolean") {
res.status(400).json({ res.status(400).json({
@ -2671,18 +2795,54 @@ export async function toggleColumnNullable(
return; return;
} }
if (nullable) { // is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL
// NOT NULL 해제 const isNullableValue = nullable ? "Y" : "N";
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`;
logger.info(`NOT NULL 해제: ${sql}`); if (!nullable) {
await query(sql); // NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인
} else { const hasCompanyCode = await query<{ column_name: string }>(
// NOT NULL 설정 `SELECT column_name FROM information_schema.columns
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; WHERE table_name = $1 AND column_name = 'company_code'`,
logger.info(`NOT NULL 설정: ${sql}`); [tableName]
await query(sql); );
if (hasCompanyCode.length > 0) {
const nullCheckQuery = companyCode === "*"
? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL`
: `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`;
const nullCheckParams = companyCode === "*" ? [] : [companyCode];
const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams);
const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10);
if (nullCount > 0) {
logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, {
companyCode,
nullCount,
});
res.status(400).json({
success: false,
message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`,
});
return;
}
}
} }
// table_type_columns에 회사별 is_nullable 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_nullable = $3, updated_date = NOW()`,
[tableName, columnName, isNullableValue, companyCode]
);
logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, {
companyCode,
});
res.status(200).json({ res.status(200).json({
success: true, success: true,
message: nullable message: nullable
@ -2692,14 +2852,95 @@ export async function toggleColumnNullable(
} catch (error: any) { } catch (error: any) {
logger.error("NOT NULL 토글 오류:", error); logger.error("NOT NULL 토글 오류:", error);
// NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내
const errorMsg = error.message?.includes("contains null values")
? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요."
: "NOT NULL 설정 중 오류가 발생했습니다.";
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: errorMsg, message: "NOT NULL 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* UNIQUE ( )
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*
* DB table_type_columns.is_unique를 .
* .
*/
export async function toggleColumnUnique(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { unique } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof unique !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, unique(boolean)이 필요합니다.",
});
return;
}
const isUniqueValue = unique ? "Y" : "N";
if (unique) {
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
const hasCompanyCode = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
const dupQuery = companyCode === "*"
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
const dupParams = companyCode === "*" ? [] : [companyCode];
const dupResult = await query<any>(dupQuery, dupParams);
if (dupResult.length > 0) {
const dupDetails = dupResult
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
.join(", ");
res.status(400).json({
success: false,
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
});
return;
}
}
}
// table_type_columns에 회사별 is_unique 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
[tableName, columnName, isUniqueValue, companyCode]
);
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
companyCode,
});
res.status(200).json({
success: true,
message: unique
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
});
} catch (error: any) {
logger.error("UNIQUE 토글 오류:", error);
res.status(500).json({
success: false,
message: "UNIQUE 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error", error: error instanceof Error ? error.message : "Unknown error",
}); });
} }

View File

@ -0,0 +1,27 @@
/**
* BOM /
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as bomController from "../controllers/bomController";
const router = Router();
router.use(authenticateToken);
// BOM 헤더 (entity join 포함)
router.get("/:bomId/header", bomController.getBomHeader);
// 이력
router.get("/:bomId/history", bomController.getBomHistory);
router.post("/:bomId/history", bomController.addBomHistory);
// 버전
router.get("/:bomId/versions", bomController.getBomVersions);
router.post("/:bomId/versions", bomController.createBomVersion);
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
export default router;

View File

@ -14,6 +14,10 @@ router.use(authenticateToken);
router.get("/items", ctrl.getItemsWithRouting); router.get("/items", ctrl.getItemsWithRouting);
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
// 기본 버전 설정/해제
router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion);
router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion);
// 작업 항목 CRUD // 작업 항목 CRUD
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems); router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
router.post("/work-items", ctrl.createWorkItem); router.post("/work-items", ctrl.createWorkItem);

View File

@ -32,6 +32,7 @@ import {
setTablePrimaryKey, // 🆕 PK 설정 setTablePrimaryKey, // 🆕 PK 설정
toggleTableIndex, // 🆕 인덱스 토글 toggleTableIndex, // 🆕 인덱스 토글
toggleColumnNullable, // 🆕 NOT NULL 토글 toggleColumnNullable, // 🆕 NOT NULL 토글
toggleColumnUnique, // 🆕 UNIQUE 토글
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex);
*/ */
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
/**
* UNIQUE
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*/
router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique);
/** /**
* *
* GET /api/table-management/tables/:tableName/exists * GET /api/table-management/tables/:tableName/exists

View File

@ -0,0 +1,292 @@
/**
* BOM
* (Row) 관리: bom_detail.version_id로
*/
import { query, queryOne, transaction } from "../database/db";
import { logger } from "../utils/logger";
function safeTableName(name: string, fallback: string): string {
if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback;
return name;
}
// ─── 이력 (History) ─────────────────────────────
export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom_history");
const sql = companyCode === "*"
? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC`
: `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`;
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
return query(sql, params);
}
export async function addBomHistory(
bomId: string,
companyCode: string,
data: {
revision?: string;
version?: string;
change_type: string;
change_description?: string;
changed_by?: string;
},
tableName?: string,
) {
const table = safeTableName(tableName || "", "bom_history");
const sql = `
INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
return queryOne(sql, [
bomId,
data.revision || null,
data.version || null,
data.change_type,
data.change_description || null,
data.changed_by || null,
companyCode,
]);
}
// ─── 버전 (Version) ─────────────────────────────
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────────
export async function getBomHeader(bomId: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom");
const sql = `
SELECT b.*,
i.item_name, i.item_number, i.division as item_type, i.unit
FROM ${table} b
LEFT JOIN item_info i ON b.item_id = i.id
WHERE b.id = $1
LIMIT 1
`;
return queryOne<Record<string, any>>(sql, [bomId]);
}
export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom_version");
const dTable = "bom_detail";
// 버전 목록 + 각 버전별 디테일 건수 + 현재 활성 버전 ID
const sql = companyCode === "*"
? `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count
FROM ${table} v WHERE v.bom_id = $1 ORDER BY v.created_date DESC`
: `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count
FROM ${table} v WHERE v.bom_id = $1 AND v.company_code = $2 ORDER BY v.created_date DESC`;
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
const versions = await query(sql, params);
// bom.current_version_id도 함께 반환
const bomRow = await queryOne<{ current_version_id: string }>(
`SELECT current_version_id FROM bom WHERE id = $1`, [bomId],
);
return {
versions,
currentVersionId: bomRow?.current_version_id || null,
};
}
/**
* 생성: 현재 bom_detail version_id로 INSERT
*/
export async function createBomVersion(
bomId: string, companyCode: string, createdBy: string,
versionTableName?: string, detailTableName?: string,
) {
const vTable = safeTableName(versionTableName || "", "bom_version");
const dTable = safeTableName(detailTableName || "", "bom_detail");
return transaction(async (client) => {
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
const bomData = bomRow.rows[0];
// 다음 버전 번호 결정
const lastVersion = await client.query(
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
[bomId],
);
let nextVersionNum = 1;
if (lastVersion.rows.length > 0) {
const parsed = parseFloat(lastVersion.rows[0].version_name);
if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1;
}
const versionName = `${nextVersionNum}.0`;
// 새 버전 레코드 생성 (snapshot_data 없이)
const insertSql = `
INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code)
VALUES ($1, $2, $3, 'developing', $4, $5)
RETURNING *
`;
const newVersion = await client.query(insertSql, [
bomId,
versionName,
bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
createdBy,
companyCode,
]);
const newVersionId = newVersion.rows[0].id;
// 현재 활성 버전의 bom_detail 행을 복사
const sourceVersionId = bomData.current_version_id;
if (sourceVersionId) {
const sourceDetails = await client.query(
`SELECT * FROM ${dTable} WHERE bom_id = $1 AND version_id = $2 ORDER BY parent_detail_id NULLS FIRST, id`,
[bomId, sourceVersionId],
);
// old ID → new ID 매핑 (parent_detail_id 유지)
const oldToNew: Record<string, string> = {};
for (const d of sourceDetails.rows) {
const insertResult = await client.query(
`INSERT INTO ${dTable} (bom_id, version_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`,
[
bomId,
newVersionId,
d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null,
d.child_item_id,
d.quantity,
d.unit,
d.process_type,
d.loss_rate,
d.remark,
d.level,
d.base_qty,
d.revision,
d.seq_no,
d.writer,
companyCode,
],
);
oldToNew[d.id] = insertResult.rows[0].id;
}
logger.info("BOM 버전 생성 - 디테일 복사 완료", {
bomId, versionName, sourceVersionId, copiedCount: sourceDetails.rows.length,
});
}
// BOM 헤더의 version과 current_version_id 갱신
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, newVersionId, bomId],
);
logger.info("BOM 버전 생성 완료", { bomId, versionName, newVersionId, companyCode });
return newVersion.rows[0];
});
}
/**
* 불러오기: bom_detail / current_version_id만
*/
export async function loadBomVersion(
bomId: string, versionId: string, companyCode: string,
versionTableName?: string, _detailTableName?: string,
) {
const vTable = safeTableName(versionTableName || "", "bom_version");
return transaction(async (client) => {
const verRow = await client.query(
`SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
const versionName = verRow.rows[0].version_name;
// BOM 헤더의 version과 current_version_id만 전환
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, versionId, bomId],
);
logger.info("BOM 버전 불러오기 완료", { bomId, versionId, versionName });
return { restored: true, versionName };
});
}
/**
* 확정: 선택 active로 + current_version_id
*/
export async function activateBomVersion(bomId: string, versionId: string, tableName?: string) {
const table = safeTableName(tableName || "", "bom_version");
return transaction(async (client) => {
const verRow = await client.query(
`SELECT version_name FROM ${table} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
// 기존 active -> inactive
await client.query(
`UPDATE ${table} SET status = 'inactive' WHERE bom_id = $1 AND status = 'active'`,
[bomId],
);
// 선택한 버전 -> active
await client.query(
`UPDATE ${table} SET status = 'active' WHERE id = $1`,
[versionId],
);
// BOM 헤더 갱신
const versionName = verRow.rows[0].version_name;
await client.query(
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
[versionName, versionId, bomId],
);
logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName });
return { activated: true, versionName };
});
}
/**
* 삭제: 해당 version_id의 bom_detail
*/
export async function deleteBomVersion(
bomId: string, versionId: string,
tableName?: string, detailTableName?: string,
) {
const table = safeTableName(tableName || "", "bom_version");
const dTable = safeTableName(detailTableName || "", "bom_detail");
return transaction(async (client) => {
// active 상태 버전은 삭제 불가
const checkResult = await client.query(
`SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`,
[versionId, bomId],
);
if (checkResult.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
if (checkResult.rows[0].status === "active") {
throw new Error("사용중인 버전은 삭제할 수 없습니다");
}
// 해당 버전의 bom_detail 행 삭제
const deleteDetails = await client.query(
`DELETE FROM ${dTable} WHERE bom_id = $1 AND version_id = $2`,
[bomId, versionId],
);
// 버전 레코드 삭제
const deleteVersion = await client.query(
`DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`,
[versionId, bomId],
);
logger.info("BOM 버전 삭제", {
bomId, versionId,
deletedDetails: deleteDetails.rowCount,
});
return deleteVersion.rows.length > 0;
});
}

View File

@ -14,6 +14,35 @@ interface NumberingRulePart {
autoConfig?: any; autoConfig?: any;
manualConfig?: any; manualConfig?: any;
generatedValue?: string; generatedValue?: string;
separatorAfter?: string;
}
/**
* autoConfig.separatorAfter를
*/
function extractSeparatorAfterFromParts(parts: any[]): any[] {
return parts.map((part) => {
if (part.autoConfig?.separatorAfter !== undefined) {
part.separatorAfter = part.autoConfig.separatorAfter;
}
return part;
});
}
/**
*
* separatorAfter는
*/
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
result += sep;
}
});
return result;
} }
interface NumberingRuleConfig { interface NumberingRuleConfig {
@ -141,7 +170,7 @@ class NumberingRuleService {
} }
const partsResult = await pool.query(partsQuery, partsParams); const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
} }
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}`, { logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}`, {
@ -274,7 +303,7 @@ class NumberingRuleService {
} }
const partsResult = await pool.query(partsQuery, partsParams); const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
} }
return result.rows; return result.rows;
@ -381,7 +410,7 @@ class NumberingRuleService {
} }
const partsResult = await pool.query(partsQuery, partsParams); const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("✅ 규칙 파트 조회 성공", { logger.info("✅ 규칙 파트 조회 성공", {
ruleId: rule.ruleId, ruleId: rule.ruleId,
@ -517,7 +546,7 @@ class NumberingRuleService {
companyCode === "*" ? rule.companyCode : companyCode, companyCode === "*" ? rule.companyCode : companyCode,
]); ]);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
} }
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}`, { logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}`, {
@ -633,7 +662,7 @@ class NumberingRuleService {
} }
const partsResult = await pool.query(partsQuery, partsParams); const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
return rule; return rule;
} }
@ -708,17 +737,25 @@ class NumberingRuleService {
manual_config AS "manualConfig" manual_config AS "manualConfig"
`; `;
// auto_config에 separatorAfter 포함
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
const partResult = await client.query(insertPartQuery, [ const partResult = await client.query(insertPartQuery, [
config.ruleId, config.ruleId,
part.order, part.order,
part.partType, part.partType,
part.generationMethod, part.generationMethod,
JSON.stringify(part.autoConfig || {}), JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}), JSON.stringify(part.manualConfig || {}),
companyCode, companyCode,
]); ]);
parts.push(partResult.rows[0]); const savedPart = partResult.rows[0];
// autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
} }
await client.query("COMMIT"); await client.query("COMMIT");
@ -820,17 +857,23 @@ class NumberingRuleService {
manual_config AS "manualConfig" manual_config AS "manualConfig"
`; `;
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
const partResult = await client.query(insertPartQuery, [ const partResult = await client.query(insertPartQuery, [
ruleId, ruleId,
part.order, part.order,
part.partType, part.partType,
part.generationMethod, part.generationMethod,
JSON.stringify(part.autoConfig || {}), JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}), JSON.stringify(part.manualConfig || {}),
companyCode, companyCode,
]); ]);
parts.push(partResult.rows[0]); const savedPart = partResult.rows[0];
if (savedPart.autoConfig?.separatorAfter !== undefined) {
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
}
parts.push(savedPart);
} }
} }
@ -1053,7 +1096,8 @@ class NumberingRuleService {
} }
})); }));
const previewCode = parts.join(rule.separator || ""); const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || "");
logger.info("코드 미리보기 생성", { logger.info("코드 미리보기 생성", {
ruleId, ruleId,
previewCode, previewCode,
@ -1164,8 +1208,8 @@ class NumberingRuleService {
} }
})); }));
const separator = rule.separator || ""; const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
const previewTemplate = previewParts.join(separator); const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
// 사용자 입력 코드에서 수동 입력 부분 추출 // 사용자 입력 코드에서 수동 입력 부분 추출
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
@ -1382,7 +1426,8 @@ class NumberingRuleService {
} }
})); }));
const allocatedCode = parts.join(rule.separator || ""); const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
// 순번이 있는 경우에만 증가 // 순번이 있는 경우에만 증가
const hasSequence = rule.parts.some( const hasSequence = rule.parts.some(
@ -1541,7 +1586,7 @@ class NumberingRuleService {
rule.ruleId, rule.ruleId,
companyCode === "*" ? rule.companyCode : companyCode, companyCode === "*" ? rule.companyCode : companyCode,
]); ]);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
} }
logger.info("[테스트] 채번 규칙 목록 조회 완료", { logger.info("[테스트] 채번 규칙 목록 조회 완료", {
@ -1634,7 +1679,7 @@ class NumberingRuleService {
rule.ruleId, rule.ruleId,
companyCode, companyCode,
]); ]);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
ruleId: rule.ruleId, ruleId: rule.ruleId,
@ -1754,12 +1799,14 @@ class NumberingRuleService {
auto_config, manual_config, company_code, created_at auto_config, manual_config, company_code, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
`; `;
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
await client.query(partInsertQuery, [ await client.query(partInsertQuery, [
config.ruleId, config.ruleId,
part.order, part.order,
part.partType, part.partType,
part.generationMethod, part.generationMethod,
JSON.stringify(part.autoConfig || {}), JSON.stringify(autoConfigWithSep),
JSON.stringify(part.manualConfig || {}), JSON.stringify(part.manualConfig || {}),
companyCode, companyCode,
]); ]);
@ -1914,7 +1961,7 @@ class NumberingRuleService {
rule.ruleId, rule.ruleId,
companyCode, companyCode,
]); ]);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("카테고리 조건 매칭 채번 규칙 찾음", { logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
ruleId: rule.ruleId, ruleId: rule.ruleId,
@ -1973,7 +2020,7 @@ class NumberingRuleService {
rule.ruleId, rule.ruleId,
companyCode, companyCode,
]); ]);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
ruleId: rule.ruleId, ruleId: rule.ruleId,
@ -2056,7 +2103,7 @@ class NumberingRuleService {
rule.ruleId, rule.ruleId,
companyCode, companyCode,
]); ]);
rule.parts = partsResult.rows; rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
} }
return result.rows; return result.rows;

View File

@ -199,7 +199,15 @@ export class TableManagementService {
cl.input_type as "cl_input_type", cl.input_type as "cl_input_type",
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings", COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
COALESCE(ttc.description, cl.description, '') as "description", COALESCE(ttc.description, cl.description, '') as "description",
c.is_nullable as "isNullable", CASE
WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL
THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue", c.column_default as "defaultValue",
c.character_maximum_length as "maxLength", c.character_maximum_length as "maxLength",
@ -241,7 +249,15 @@ export class TableManagementService {
COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.input_type, 'direct') as "inputType",
COALESCE(cl.detail_settings::text, '') as "detailSettings", COALESCE(cl.detail_settings::text, '') as "detailSettings",
COALESCE(cl.description, '') as "description", COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable", CASE
WHEN cl.is_nullable IS NOT NULL
THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN cl.is_unique = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue", c.column_default as "defaultValue",
c.character_maximum_length as "maxLength", c.character_maximum_length as "maxLength",
@ -502,8 +518,8 @@ export class TableManagementService {
table_name, column_name, column_label, input_type, detail_settings, table_name, column_name, column_label, input_type, detail_settings,
code_category, code_value, reference_table, reference_column, code_category, code_value, reference_table, reference_column,
display_column, display_order, is_visible, is_nullable, display_column, display_order, is_visible, is_nullable,
company_code, created_date, updated_date company_code, category_ref, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW()) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code) ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
@ -516,6 +532,7 @@ export class TableManagementService {
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
category_ref = EXCLUDED.category_ref,
updated_date = NOW()`, updated_date = NOW()`,
[ [
tableName, tableName,
@ -531,6 +548,7 @@ export class TableManagementService {
settings.displayOrder || 0, settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true, settings.isVisible !== undefined ? settings.isVisible : true,
companyCode, companyCode,
settings.categoryRef || null,
] ]
); );
@ -1599,7 +1617,8 @@ export class TableManagementService {
tableName, tableName,
columnName, columnName,
actualValue, actualValue,
paramIndex paramIndex,
operator
); );
case "entity": case "entity":
@ -1612,7 +1631,14 @@ export class TableManagementService {
); );
default: default:
// 기본 문자열 검색 (actualValue 사용) // operator에 따라 정확 일치 또는 부분 일치 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(actualValue)],
paramCount: 1,
};
}
return { return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`, whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${actualValue}%`], values: [`%${actualValue}%`],
@ -1626,10 +1652,19 @@ export class TableManagementService {
); );
// 오류 시 기본 검색으로 폴백 // 오류 시 기본 검색으로 폴백
let fallbackValue = value; let fallbackValue = value;
let fallbackOperator = "contains";
if (typeof value === "object" && value !== null && "value" in value) { if (typeof value === "object" && value !== null && "value" in value) {
fallbackValue = value.value; fallbackValue = value.value;
fallbackOperator = value.operator || "contains";
} }
if (fallbackOperator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(fallbackValue)],
paramCount: 1,
};
}
return { return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`, whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${fallbackValue}%`], values: [`%${fallbackValue}%`],
@ -1776,7 +1811,8 @@ export class TableManagementService {
tableName: string, tableName: string,
columnName: string, columnName: string,
value: any, value: any,
paramIndex: number paramIndex: number,
operator: string = "contains"
): Promise<{ ): Promise<{
whereClause: string; whereClause: string;
values: any[]; values: any[];
@ -1786,7 +1822,14 @@ export class TableManagementService {
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName); const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) { if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
// 코드 타입이 아니면 기본 검색 // 코드 타입이 아니면 operator에 따라 검색
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
return { return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`, whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`], values: [`%${value}%`],
@ -1794,6 +1837,15 @@ export class TableManagementService {
}; };
} }
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [String(value)],
paramCount: 1,
};
}
if (typeof value === "string" && value.trim() !== "") { if (typeof value === "string" && value.trim() !== "") {
// 코드값 또는 코드명으로 검색 // 코드값 또는 코드명으로 검색
return { return {
@ -2431,6 +2483,154 @@ export class TableManagementService {
return value; return value;
} }
/**
* NOT NULL
* table_type_columns.is_nullable = 'N' NULL/ .
*/
async validateNotNullConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string
): Promise<string[]> {
try {
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
const notNullColumns = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_nullable = 'N'
AND ttc.company_code = $2`,
[tableName, companyCode]
);
// 회사별 설정이 없으면 공통 설정 확인
if (notNullColumns.length === 0 && companyCode !== "*") {
const globalNotNull = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_nullable = 'N'
AND ttc.company_code = '*'
AND NOT EXISTS (
SELECT 1 FROM table_type_columns ttc2
WHERE ttc2.table_name = ttc.table_name
AND ttc2.column_name = ttc.column_name
AND ttc2.company_code = $2
)`,
[tableName, companyCode]
);
notNullColumns.push(...globalNotNull);
}
if (notNullColumns.length === 0) return [];
const violations: string[] = [];
for (const col of notNullColumns) {
const value = data[col.column_name];
// NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리
if (value === null || value === undefined || value === "") {
violations.push(col.column_label);
}
}
return violations;
} catch (error) {
logger.error(`NOT NULL 검증 오류: ${tableName}`, error);
return [];
}
}
/**
* UNIQUE
* table_type_columns.is_unique = 'Y' .
* @param excludeId
*/
async validateUniqueConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string,
excludeId?: string
): Promise<string[]> {
try {
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
let uniqueColumns = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = $2`,
[tableName, companyCode]
);
// 회사별 설정이 없으면 공통 설정 확인
if (uniqueColumns.length === 0 && companyCode !== "*") {
const globalUnique = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = '*'
AND NOT EXISTS (
SELECT 1 FROM table_type_columns ttc2
WHERE ttc2.table_name = ttc.table_name
AND ttc2.column_name = ttc.column_name
AND ttc2.company_code = $2
)`,
[tableName, companyCode]
);
uniqueColumns = globalUnique;
}
if (uniqueColumns.length === 0) return [];
const violations: string[] = [];
for (const col of uniqueColumns) {
const value = data[col.column_name];
if (value === null || value === undefined || value === "") continue;
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
const hasCompanyCode = await query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
let dupQuery: string;
let dupParams: any[];
if (hasCompanyCode.length > 0 && companyCode !== "*") {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
} else {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
dupParams = excludeId ? [value, excludeId] : [value];
}
const dupResult = await query(dupQuery, dupParams);
if (dupResult.length > 0) {
violations.push(`${col.column_label} (${value})`);
}
}
return violations;
} catch (error) {
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
return [];
}
}
/** /**
* *
* @returns () * @returns ()
@ -2438,7 +2638,7 @@ export class TableManagementService {
async addTableData( async addTableData(
tableName: string, tableName: string,
data: Record<string, any> data: Record<string, any>
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> { ): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> {
try { try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data); logger.info(`추가할 데이터:`, data);
@ -2551,19 +2751,21 @@ export class TableManagementService {
const insertQuery = ` const insertQuery = `
INSERT INTO "${tableName}" (${columnNames}) INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders}) VALUES (${placeholders})
RETURNING id
`; `;
logger.info(`실행할 쿼리: ${insertQuery}`); logger.info(`실행할 쿼리: ${insertQuery}`);
logger.info(`쿼리 파라미터:`, values); logger.info(`쿼리 파라미터:`, values);
await query(insertQuery, values); const insertResult = await query(insertQuery, values) as any[];
const insertedId = insertResult?.[0]?.id ?? null;
logger.info(`테이블 데이터 추가 완료: ${tableName}`); logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`);
// 무시된 컬럼과 저장된 컬럼 정보 반환
return { return {
skippedColumns, skippedColumns,
savedColumns: existingColumns, savedColumns: existingColumns,
insertedId,
}; };
} catch (error) { } catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
@ -4353,7 +4555,8 @@ export class TableManagementService {
END as "detailSettings", END as "detailSettings",
ttc.is_nullable as "isNullable", ttc.is_nullable as "isNullable",
ic.data_type as "dataType", ic.data_type as "dataType",
ttc.company_code as "companyCode" ttc.company_code as "companyCode",
ttc.category_ref as "categoryRef"
FROM table_type_columns ttc FROM table_type_columns ttc
LEFT JOIN information_schema.columns ic LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
@ -4430,20 +4633,24 @@ export class TableManagementService {
} }
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => { const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
const baseInfo = { const baseInfo: any = {
tableName: tableName, tableName: tableName,
columnName: col.columnName, columnName: col.columnName,
displayName: col.displayName, displayName: col.displayName,
dataType: col.dataType || "varchar", dataType: col.dataType || "varchar",
inputType: col.inputType, inputType: col.inputType,
detailSettings: col.detailSettings, detailSettings: col.detailSettings,
description: "", // 필수 필드 추가 description: "",
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환 isNullable: col.isNullable === "Y" ? "Y" : "N",
isPrimaryKey: false, isPrimaryKey: false,
displayOrder: 0, displayOrder: 0,
isVisible: true, isVisible: true,
}; };
if (col.categoryRef) {
baseInfo.categoryRef = col.categoryRef;
}
// 카테고리 타입인 경우 categoryMenus 추가 // 카테고리 타입인 경우 categoryMenus 추가
if ( if (
col.inputType === "category" && col.inputType === "category" &&

278
docs/BOM_개발_현황.md Normal file
View File

@ -0,0 +1,278 @@
# BOM 관리 시스템 개발 현황
## 1. 개요
BOM(Bill of Materials) 관리 시스템은 제품의 구성 부품을 계층적으로 관리하는 기능입니다.
V2 컴포넌트 기반으로 구현되어 있으며, 설정 패널을 통해 모든 기능을 동적으로 구성할 수 있습니다.
---
## 2. 아키텍처
### 2.1 전체 구조
```
[프론트엔드] [백엔드] [데이터베이스]
v2-bom-tree (트리 뷰) ──── /api/bom ────── bomService.ts ────── bom, bom_detail
v2-bom-item-editor ──── /api/table-management ──────────── bom_history, bom_version
V2BomTreeConfigPanel (설정 패널)
```
### 2.2 관련 파일 목록
#### 프론트엔드
| 파일 | 설명 |
|------|------|
| `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` | BOM 트리/레벨 뷰 메인 컴포넌트 |
| `frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx` | 버전 관리 모달 |
| `frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx` | 이력 관리 모달 |
| `frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx` | BOM 항목 수정 모달 |
| `frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx` | 트리 렌더러 |
| `frontend/lib/registry/components/v2-bom-tree/index.ts` | 컴포넌트 정의 (v2-bom-tree) |
| `frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx` | BOM 트리 설정 패널 |
| `frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx` | BOM 항목 편집기 (에디터 모드) |
#### 백엔드
| 파일 | 설명 |
|------|------|
| `backend-node/src/routes/bomRoutes.ts` | BOM API 라우트 정의 |
| `backend-node/src/controllers/bomController.ts` | BOM 컨트롤러 (이력/버전) |
| `backend-node/src/services/bomService.ts` | BOM 서비스 (비즈니스 로직) |
#### 데이터베이스
| 파일 | 설명 |
|------|------|
| `db/migrations/062_create_bom_history_version_tables.sql` | 이력/버전 테이블 DDL |
---
## 3. 데이터베이스 스키마
### 3.1 bom (BOM 헤더)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR (UUID) | PK |
| item_id | VARCHAR | 완제품 품목 ID (item_info FK) |
| bom_name | VARCHAR | BOM 명칭 |
| version | VARCHAR | 현재 사용중인 버전명 |
| revision | VARCHAR | 차수 |
| base_qty | NUMERIC | 기준수량 |
| unit | VARCHAR | 단위 |
| remark | TEXT | 비고 |
| company_code | VARCHAR | 회사 코드 (멀티테넌시) |
### 3.2 bom_detail (BOM 상세 - 자식 품목)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR (UUID) | PK |
| bom_id | VARCHAR | BOM 헤더 FK |
| parent_detail_id | VARCHAR | 부모 detail FK (NULL = 1레벨) |
| child_item_id | VARCHAR | 자식 품목 ID (item_info FK) |
| quantity | NUMERIC | 구성수량 (소요량) |
| unit | VARCHAR | 단위 |
| process_type | VARCHAR | 공정구분 (제조/외주 등) |
| loss_rate | NUMERIC | 손실율 |
| level | INTEGER | 레벨 |
| base_qty | NUMERIC | 기준수량 |
| revision | VARCHAR | 차수 |
| remark | TEXT | 비고 |
| company_code | VARCHAR | 회사 코드 |
### 3.3 bom_history (BOM 이력)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR (UUID) | PK |
| bom_id | VARCHAR | BOM 헤더 FK |
| revision | VARCHAR | 차수 |
| version | VARCHAR | 버전 |
| change_type | VARCHAR | 변경구분 (등록/수정/추가/삭제) |
| change_description | TEXT | 변경내용 |
| changed_by | VARCHAR | 변경자 |
| changed_date | TIMESTAMP | 변경일시 |
| company_code | VARCHAR | 회사 코드 |
### 3.4 bom_version (BOM 버전)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | VARCHAR (UUID) | PK |
| bom_id | VARCHAR | BOM 헤더 FK |
| version_name | VARCHAR | 버전명 (1.0, 2.0 ...) |
| revision | INTEGER | 생성 시점의 차수 |
| status | VARCHAR | 상태 (developing / active / inactive) |
| snapshot_data | JSONB | 스냅샷 (bom 헤더 + bom_detail 전체) |
| created_by | VARCHAR | 생성자 |
| created_date | TIMESTAMP | 생성일시 |
| company_code | VARCHAR | 회사 코드 |
---
## 4. API 명세
### 4.1 이력 API
| Method | Path | 설명 |
|--------|------|------|
| GET | `/api/bom/:bomId/history` | 이력 목록 조회 |
| POST | `/api/bom/:bomId/history` | 이력 등록 |
**Query Params**: `tableName` (설정 패널에서 지정한 이력 테이블명, 기본값: `bom_history`)
### 4.2 버전 API
| Method | Path | 설명 |
|--------|------|------|
| GET | `/api/bom/:bomId/versions` | 버전 목록 조회 |
| POST | `/api/bom/:bomId/versions` | 신규 버전 생성 |
| POST | `/api/bom/:bomId/versions/:versionId/load` | 버전 불러오기 (데이터 복원) |
| POST | `/api/bom/:bomId/versions/:versionId/activate` | 버전 사용 확정 |
| DELETE | `/api/bom/:bomId/versions/:versionId` | 버전 삭제 |
**Body/Query**: `tableName`, `detailTable` (설정 패널에서 지정한 테이블명)
---
## 5. 버전 관리 구조
### 5.1 핵심 원리
**각 버전은 생성 시점의 BOM 전체 구조(헤더 + 모든 디테일)를 JSONB 스냅샷으로 저장합니다.**
```
버전 1.0 (active)
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
버전 2.0 (developing)
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
버전 3.0 (inactive)
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
```
### 5.2 버전 상태 (status)
| 상태 | 설명 |
|------|------|
| `developing` | 개발중 - 신규 생성 시 기본 상태 |
| `active` | 사용중 - "사용 확정" 후 운영 상태 |
| `inactive` | 사용중지 - 이전에 active였다가 다른 버전이 확정된 경우 |
### 5.3 버전 워크플로우
```
[현재 BOM 데이터]
신규 버전 생성 ───► 버전 N.0 (status: developing)
├── 불러오기: 해당 스냅샷의 데이터로 현재 BOM을 복원
│ (status 변경 없음, BOM 헤더 version 변경 없음)
├── 사용 확정: status → active,
│ 기존 active 버전 → inactive,
│ BOM 헤더의 version 필드 갱신
└── 삭제: active 상태가 아닌 경우만 삭제 가능
```
### 5.4 불러오기 vs 사용 확정
| 동작 | 불러오기 (Load) | 사용 확정 (Activate) |
|------|----------------|---------------------|
| BOM 데이터 복원 | O (detail 전체 교체) | X |
| BOM 헤더 업데이트 | O (base_qty, unit 등) | version 필드만 |
| 버전 status 변경 | X | active로 변경 |
| 기존 active 비활성화 | X | O (→ inactive) |
| BOM 목록 새로고침 | O (refreshTable) | O (refreshTable) |
---
## 6. 설정 패널 구성
`V2BomTreeConfigPanel.tsx`에서 아래 항목을 설정할 수 있습니다:
### 6.1 기본 탭
| 설정 항목 | 설명 | 기본값 |
|-----------|------|--------|
| 디테일 테이블 | BOM 상세 데이터 테이블 | `bom_detail` |
| 외래키 | BOM 헤더와의 연결 키 | `bom_id` |
| 부모키 | 부모-자식 관계 키 | `parent_detail_id` |
| 이력 테이블 | BOM 변경 이력 테이블 | `bom_history` |
| 버전 테이블 | BOM 버전 관리 테이블 | `bom_version` |
| 이력 기능 표시 | 이력 버튼 노출 여부 | `true` |
| 버전 기능 표시 | 버전 버튼 노출 여부 | `true` |
### 6.2 컬럼 탭
- 소스 테이블 (bom/item_info 등)에서 표시할 컬럼 선택
- 디테일 테이블에서 표시할 컬럼 선택
- 컬럼 순서 드래그앤드롭
- 컬럼별 라벨, 너비, 정렬 설정
---
## 7. 뷰 모드
### 7.1 트리 뷰 (기본)
- 계층적 들여쓰기로 부모-자식 관계 표현
- 레벨별 시각 구분:
- **0레벨 (가상 루트)**: 파란색 배경 + 파란 좌측 바
- **1레벨**: 흰색 배경 + 초록 좌측 바
- **2레벨**: 연회색 배경 + 주황 좌측 바
- **3레벨 이상**: 진회색 배경 + 보라 좌측 바
- 펼침/접힘 (정전개/역전개)
### 7.2 레벨 뷰
- 평면 테이블 형태로 표시
- "레벨0", "레벨1", "레벨2" ... 컬럼에 체크마크로 계층 표시
- 같은 레벨별 배경색 구분 적용
---
## 8. 주요 기능 목록
| 기능 | 상태 | 설명 |
|------|------|------|
| BOM 트리 표시 | 완료 | 계층적 트리 뷰 + 레벨 뷰 |
| BOM 항목 편집 | 완료 | 더블클릭으로 수정 모달 (0레벨: bom, 하위: bom_detail) |
| 이력 관리 | 완료 | 변경 이력 조회/등록 모달 |
| 버전 관리 | 완료 | 버전 생성/불러오기/사용 확정/삭제 |
| 설정 패널 | 완료 | 테이블/컬럼/기능 동적 설정 |
| 디자인 모드 프리뷰 | 완료 | 실제 화면과 일치하는 디자인 모드 표시 |
| 컬럼 크기 조절 | 완료 | 헤더 드래그로 컬럼 너비 변경 |
| 텍스트 말줄임 | 완료 | 긴 텍스트 `...` 처리 |
| 레벨별 시각 구분 | 완료 | 배경색 + 좌측 컬러 바 |
| 정전개/역전개 | 완료 | 전체 펼침/접기 토글 |
| 좌우 스크롤 | 완료 | 컬럼 크기가 커질 때 수평 스크롤 |
| BOM 목록 자동 새로고침 | 완료 | 버전 불러오기/확정 후 좌측 패널 자동 리프레시 |
| BOM 하위 품목 저장 | 완료 | BomItemEditorComponent에서 직접 INSERT/UPDATE/DELETE |
| 차수 (Revision) 자동 증가 | 미구현 | BOM 변경 시 헤더 revision 자동 +1 |
---
## 9. 보안 고려사항
- **SQL 인젝션 방지**: `safeTableName()` 함수로 테이블명 검증 (`^[a-zA-Z_][a-zA-Z0-9_]*$`)
- **멀티테넌시**: 모든 API에서 `company_code` 필터링 적용
- **최고 관리자**: `company_code = "*"` 시 전체 데이터 조회 가능
- **인증**: `authenticateToken` 미들웨어로 모든 라우트 보호
---
## 10. 향후 개선 사항
- [ ] 차수(Revision) 자동 증가 구현 (BOM 헤더 레벨)
- [ ] 버전 비교 기능 (두 버전 간 diff)
- [ ] BOM 복사 기능
- [ ] 이력 자동 등록 (수정/저장 시 자동으로 이력 생성)
- [ ] Excel 내보내기/가져오기
- [ ] BOM 유효성 검증 (순환참조 방지 등)

View File

@ -63,6 +63,7 @@ interface ColumnTypeInfo {
detailSettings: string; detailSettings: string;
description: string; description: string;
isNullable: string; isNullable: string;
isUnique: string;
defaultValue?: string; defaultValue?: string;
maxLength?: number; maxLength?: number;
numericPrecision?: number; numericPrecision?: number;
@ -72,9 +73,10 @@ interface ColumnTypeInfo {
referenceTable?: string; referenceTable?: string;
referenceColumn?: string; referenceColumn?: string;
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열 categoryMenus?: number[];
hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할 hierarchyRole?: "large" | "medium" | "small";
numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID numberingRuleId?: string;
categoryRef?: string | null;
} }
interface SecondLevelMenu { interface SecondLevelMenu {
@ -382,10 +384,12 @@ export default function TableManagementPage() {
return { return {
...col, ...col,
inputType: col.inputType || "text", // 기본값: text inputType: col.inputType || "text",
numberingRuleId, // 🆕 채번규칙 ID isUnique: col.isUnique || "NO",
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 numberingRuleId,
hierarchyRole, // 계층구조 역할 categoryMenus: col.categoryMenus || [],
hierarchyRole,
categoryRef: col.categoryRef || null,
}; };
}); });
@ -668,15 +672,16 @@ export default function TableManagementPage() {
} }
const columnSetting = { const columnSetting = {
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnName: column.columnName,
columnLabel: column.displayName, // 사용자가 입력한 표시명 columnLabel: column.displayName,
inputType: column.inputType || "text", inputType: column.inputType || "text",
detailSettings: finalDetailSettings, detailSettings: finalDetailSettings,
codeCategory: column.codeCategory || "", codeCategory: column.codeCategory || "",
codeValue: column.codeValue || "", codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "", referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn || "", referenceColumn: column.referenceColumn || "",
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명 displayColumn: column.displayColumn || "",
categoryRef: column.categoryRef || null,
}; };
// console.log("저장할 컬럼 설정:", columnSetting); // console.log("저장할 컬럼 설정:", columnSetting);
@ -703,9 +708,9 @@ export default function TableManagementPage() {
length: column.categoryMenus?.length || 0, length: column.categoryMenus?.length || 0,
}); });
if (column.inputType === "category") { if (column.inputType === "category" && !column.categoryRef) {
// 1. 먼저 기존 매핑 모두 삭제 // 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", { console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
tableName: selectedTable, tableName: selectedTable,
columnName: column.columnName, columnName: column.columnName,
}); });
@ -864,8 +869,8 @@ export default function TableManagementPage() {
} }
return { return {
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnName: column.columnName,
columnLabel: column.displayName, // 사용자가 입력한 표시명 columnLabel: column.displayName,
inputType: column.inputType || "text", inputType: column.inputType || "text",
detailSettings: finalDetailSettings, detailSettings: finalDetailSettings,
description: column.description || "", description: column.description || "",
@ -873,7 +878,8 @@ export default function TableManagementPage() {
codeValue: column.codeValue || "", codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "", referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn || "", referenceColumn: column.referenceColumn || "",
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명 displayColumn: column.displayColumn || "",
categoryRef: column.categoryRef || null,
}; };
}); });
@ -886,8 +892,8 @@ export default function TableManagementPage() {
); );
if (response.data.success) { if (response.data.success) {
// 🆕 Category 타입 컬럼들의 메뉴 매핑 처리 // 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
const categoryColumns = columns.filter((col) => col.inputType === "category"); const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef);
console.log("📥 전체 저장: 카테고리 컬럼 확인", { console.log("📥 전체 저장: 카테고리 컬럼 확인", {
totalColumns: columns.length, totalColumns: columns.length,
@ -1091,9 +1097,9 @@ export default function TableManagementPage() {
} }
}; };
// 인덱스 토글 핸들러 // 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
const handleIndexToggle = useCallback( const handleIndexToggle = useCallback(
async (columnName: string, indexType: "index" | "unique", checked: boolean) => { async (columnName: string, indexType: "index", checked: boolean) => {
if (!selectedTable) return; if (!selectedTable) return;
const action = checked ? "create" : "drop"; const action = checked ? "create" : "drop";
try { try {
@ -1122,14 +1128,41 @@ export default function TableManagementPage() {
const hasIndex = constraints.indexes.some( const hasIndex = constraints.indexes.some(
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
); );
const hasUnique = constraints.indexes.some( return { isPk, hasIndex };
(idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
return { isPk, hasIndex, hasUnique };
}, },
[constraints], [constraints],
); );
// UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴)
const handleUniqueToggle = useCallback(
async (columnName: string, currentIsUnique: string) => {
if (!selectedTable) return;
const isCurrentlyUnique = currentIsUnique === "YES";
const newUnique = !isCurrentlyUnique;
try {
const response = await apiClient.put(
`/table-management/tables/${selectedTable}/columns/${columnName}/unique`,
{ unique: newUnique },
);
if (response.data.success) {
toast.success(response.data.message);
setColumns((prev) =>
prev.map((col) =>
col.columnName === columnName
? { ...col, isUnique: newUnique ? "YES" : "NO" }
: col,
),
);
} else {
toast.error(response.data.message || "UNIQUE 설정 실패");
}
} catch (error: any) {
toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다.");
}
},
[selectedTable],
);
// NOT NULL 토글 핸들러 // NOT NULL 토글 핸들러
const handleNullableToggle = useCallback( const handleNullableToggle = useCallback(
async (columnName: string, currentIsNullable: string) => { async (columnName: string, currentIsNullable: string) => {
@ -1662,7 +1695,30 @@ export default function TableManagementPage() {
)} )}
</> </>
)} )}
{/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */} {/* 카테고리 타입: 참조 설정 */}
{column.inputType === "category" && (
<div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> ()</label>
<Input
value={column.categoryRef || ""}
onChange={(e) => {
const val = e.target.value || null;
setColumns((prev) =>
prev.map((c) =>
c.columnName === column.columnName
? { ...c, categoryRef: val }
: c
)
);
}}
placeholder="테이블명.컬럼명"
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-0.5 text-[10px]">
</p>
</div>
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && ( {column.inputType === "entity" && (
<> <>
@ -2029,12 +2085,12 @@ export default function TableManagementPage() {
aria-label={`${column.columnName} 인덱스 설정`} aria-label={`${column.columnName} 인덱스 설정`}
/> />
</div> </div>
{/* UQ 체크박스 */} {/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
<div className="flex items-center justify-center pt-1"> <div className="flex items-center justify-center pt-1">
<Checkbox <Checkbox
checked={idxState.hasUnique} checked={column.isUnique === "YES"}
onCheckedChange={(checked) => onCheckedChange={() =>
handleIndexToggle(column.columnName, "unique", checked as boolean) handleUniqueToggle(column.columnName, column.isUnique)
} }
aria-label={`${column.columnName} 유니크 설정`} aria-label={`${column.columnName} 유니크 설정`}
/> />

View File

@ -87,10 +87,12 @@ function ScreenViewPage() {
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({}); const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
// 🆕 레이어 시스템 지원 // 레이어 시스템 지원
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]); const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
// 🆕 조건부 영역(Zone) 목록 // 조건부 영역(Zone) 목록
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]); const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
// 편집 모달 상태 // 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
@ -378,11 +380,51 @@ function ScreenViewPage() {
} }
}); });
return newActiveIds; // 강제 활성화된 레이어 ID 병합
}, [formData, conditionalLayers, layout]); for (const forcedId of forceActivatedLayerIds) {
if (!newActiveIds.includes(forcedId)) {
newActiveIds.push(forcedId);
}
}
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼) return newActiveIds;
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움 }, [formData, conditionalLayers, layout, forceActivatedLayerIds]);
// 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너
useEffect(() => {
const handleActivateLayer = (e: Event) => {
const { componentId, targetLayerId } = (e as CustomEvent).detail || {};
if (!componentId && !targetLayerId) return;
// targetLayerId가 직접 지정된 경우
if (targetLayerId) {
setForceActivatedLayerIds((prev) =>
prev.includes(targetLayerId) ? prev : [...prev, targetLayerId],
);
console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`);
return;
}
// componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화
for (const layer of conditionalLayers) {
const found = layer.components.some((comp) => comp.id === componentId);
if (found) {
setForceActivatedLayerIds((prev) =>
prev.includes(layer.id) ? prev : [...prev, layer.id],
);
console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`);
return;
}
}
};
window.addEventListener("activateLayerForComponent", handleActivateLayer);
return () => {
window.removeEventListener("activateLayerForComponent", handleActivateLayer);
};
}, [conditionalLayers]);
// 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
useEffect(() => { useEffect(() => {
const loadMainTableData = async () => { const loadMainTableData = async () => {
if (!screen || !layout || !layout.components || !companyCode) { if (!screen || !layout || !layout.components || !companyCode) {

View File

@ -3,6 +3,7 @@
import { useEffect, ReactNode } from "react"; import { useEffect, ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { AuthLogger } from "@/lib/authLogger";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
interface AuthGuardProps { interface AuthGuardProps {
@ -41,11 +42,13 @@ export function AuthGuard({
} }
if (requireAuth && !isLoggedIn) { if (requireAuth && !isLoggedIn) {
AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`);
router.push(redirectTo); router.push(redirectTo);
return; return;
} }
if (requireAdmin && !isAdmin) { if (requireAdmin && !isAdmin) {
AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`);
router.push(redirectTo); router.push(redirectTo);
return; return;
} }

View File

@ -942,17 +942,22 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
continue; continue;
} }
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용) // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
try { const existingValue = dataToSave[numberingInfo.columnName];
const { apiClient } = await import("@/lib/api/client"); const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; if (!hasExcelValue) {
if (numberingResponse.data?.success && generatedCode) { try {
dataToSave[numberingInfo.columnName] = generatedCode; const { apiClient } = await import("@/lib/api/client");
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
if (numberingResponse.data?.success && generatedCode) {
dataToSave[numberingInfo.columnName] = generatedCode;
}
} catch (numError) {
console.error("채번 오류:", numError);
} }
} catch (numError) {
console.error("채번 오류:", numError);
} }
} }

View File

@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
interface ScreenModalState { interface ScreenModalState {
isOpen: boolean; isOpen: boolean;
@ -1025,6 +1026,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div> </div>
</div> </div>
) : screenData ? ( ) : screenData ? (
<ScreenContextProvider
screenId={modalState.screenId || undefined}
tableName={screenData.screenInfo?.tableName}
>
<ActiveTabProvider> <ActiveTabProvider>
<TableOptionsProvider> <TableOptionsProvider>
<div <div
@ -1254,6 +1259,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div> </div>
</TableOptionsProvider> </TableOptionsProvider>
</ActiveTabProvider> </ActiveTabProvider>
</ScreenContextProvider>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p> <p className="text-muted-foreground"> .</p>

View File

@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
isPreview = false, isPreview = false,
}) => { }) => {
return ( return (
<Card className="border-border bg-card"> <Card className="border-border bg-card flex-1">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs sm:text-sm"> <Badge variant="outline" className="text-xs sm:text-sm">

View File

@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingLeftTitle, setEditingLeftTitle] = useState(false);
const [editingRightTitle, setEditingRightTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false);
// 구분자 관련 상태 // 구분자 관련 상태 (개별 파트 사이 구분자)
const [separatorType, setSeparatorType] = useState<SeparatorType>("-"); const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
const [customSeparator, setCustomSeparator] = useState(""); const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회 // 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
interface CategoryOption { interface CategoryOption {
@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} }
}, [currentRule, onChange]); }, [currentRule, onChange]);
// currentRule이 변경될 때 구분자 상태 동기화 // currentRule이 변경될 때 파트별 구분자 상태 동기화
useEffect(() => { useEffect(() => {
if (currentRule) { if (currentRule && currentRule.parts.length > 0) {
const sep = currentRule.separator ?? "-"; const newSepTypes: Record<number, SeparatorType> = {};
// 빈 문자열이면 "none" const newCustomSeps: Record<number, string> = {};
if (sep === "") {
setSeparatorType("none");
setCustomSeparator("");
return;
}
// 미리 정의된 구분자인지 확인 (none, custom 제외)
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
);
if (predefinedOption) {
setSeparatorType(predefinedOption.value);
setCustomSeparator("");
} else {
// 직접 입력된 구분자
setSeparatorType("custom");
setCustomSeparator(sep);
}
}
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
// 구분자 변경 핸들러 currentRule.parts.forEach((part) => {
const handleSeparatorChange = useCallback((type: SeparatorType) => { const sep = part.separatorAfter ?? currentRule.separator ?? "-";
setSeparatorType(type); if (sep === "") {
newSepTypes[part.order] = "none";
newCustomSeps[part.order] = "";
} else {
const predefinedOption = SEPARATOR_OPTIONS.find(
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
);
if (predefinedOption) {
newSepTypes[part.order] = predefinedOption.value;
newCustomSeps[part.order] = "";
} else {
newSepTypes[part.order] = "custom";
newCustomSeps[part.order] = sep;
}
}
});
setSeparatorTypes(newSepTypes);
setCustomSeparators(newCustomSeps);
}
}, [currentRule?.ruleId]);
// 개별 파트 구분자 변경 핸들러
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
if (type !== "custom") { if (type !== "custom") {
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
const newSeparator = option?.displayValue ?? ""; const newSeparator = option?.displayValue ?? "";
setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null); setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
setCustomSeparator(""); setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
),
};
});
} }
}, []); }, []);
// 직접 입력 구분자 변경 핸들러 // 개별 파트 직접 입력 구분자 변경 핸들러
const handleCustomSeparatorChange = useCallback((value: string) => { const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
// 최대 2자 제한
const trimmedValue = value.slice(0, 2); const trimmedValue = value.slice(0, 2);
setCustomSeparator(trimmedValue); setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null); setCurrentRule((prev) => {
if (!prev) return null;
return {
...prev,
parts: prev.parts.map((part) =>
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
),
};
});
}, []); }, []);
const handleAddPart = useCallback(() => { const handleAddPart = useCallback(() => {
@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
partType: "text", partType: "text",
generationMethod: "auto", generationMethod: "auto",
autoConfig: { textValue: "CODE" }, autoConfig: { textValue: "CODE" },
separatorAfter: "-",
}; };
setCurrentRule((prev) => { setCurrentRule((prev) => {
@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
return { ...prev, parts: [...prev.parts, newPart] }; return { ...prev, parts: [...prev.parts, newPart] };
}); });
// 새 파트의 구분자 상태 초기화
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
toast.success(`규칙 ${newPart.order}가 추가되었습니다`); toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
}, [currentRule, maxRules]); }, [currentRule, maxRules]);
@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
</div> </div>
</div> </div>
{/* 두 번째 줄: 구분자 설정 */}
<div className="flex items-end gap-3">
<div className="w-48 space-y-2">
<Label className="text-sm font-medium"></Label>
<Select
value={separatorType}
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="구분자 선택" />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{separatorType === "custom" && (
<div className="w-32 space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
value={customSeparator}
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
className="h-9"
placeholder="최대 2자"
maxLength={2}
/>
</div>
)}
<p className="text-muted-foreground pb-2 text-xs">
</p>
</div>
</div> </div>
@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
<p className="text-muted-foreground text-xs sm:text-sm"> </p> <p className="text-muted-foreground text-xs sm:text-sm"> </p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"> <div className="flex flex-wrap items-stretch gap-3">
{currentRule.parts.map((part, index) => ( {currentRule.parts.map((part, index) => (
<NumberingRuleCard <React.Fragment key={`part-${part.order}-${index}`}>
key={`part-${part.order}-${index}`} <div className="flex w-[200px] flex-col">
part={part} <NumberingRuleCard
onUpdate={(updates) => handleUpdatePart(part.order, updates)} part={part}
onDelete={() => handleDeletePart(part.order)} onUpdate={(updates) => handleUpdatePart(part.order, updates)}
isPreview={isPreview} onDelete={() => handleDeletePart(part.order)}
/> isPreview={isPreview}
/>
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
{index < currentRule.parts.length - 1 && (
<div className="mt-2 flex items-center gap-1">
<span className="text-muted-foreground text-[10px] whitespace-nowrap"> </span>
<Select
value={separatorTypes[part.order] || "-"}
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
>
<SelectTrigger className="h-6 flex-1 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEPARATOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{separatorTypes[part.order] === "custom" && (
<Input
value={customSeparators[part.order] || ""}
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
className="h-6 w-14 text-center text-[10px]"
placeholder="2자"
maxLength={2}
/>
)}
</div>
)}
</div>
</React.Fragment>
))} ))}
</div> </div>
)} )}

View File

@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
return "규칙을 추가해주세요"; return "규칙을 추가해주세요";
} }
const parts = config.parts const sortedParts = config.parts.sort((a, b) => a.order - b.order);
.sort((a, b) => a.order - b.order)
.map((part) => { const partValues = sortedParts.map((part) => {
if (part.generationMethod === "manual") { if (part.generationMethod === "manual") {
return part.manualConfig?.value || "XXX"; return part.manualConfig?.value || "XXX";
}
const autoConfig = part.autoConfig || {};
switch (part.partType) {
case "sequence": {
const length = autoConfig.sequenceLength || 3;
const startFrom = autoConfig.startFrom || 1;
return String(startFrom).padStart(length, "0");
} }
case "number": {
const autoConfig = part.autoConfig || {}; const length = autoConfig.numberLength || 4;
const value = autoConfig.numberValue || 0;
switch (part.partType) { return String(value).padStart(length, "0");
// 1. 순번 (자동 증가) }
case "sequence": { case "date": {
const length = autoConfig.sequenceLength || 3; const format = autoConfig.dateFormat || "YYYYMMDD";
const startFrom = autoConfig.startFrom || 1; if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
return String(startFrom).padStart(length, "0");
}
// 2. 숫자 (고정 자릿수)
case "number": {
const length = autoConfig.numberLength || 4;
const value = autoConfig.numberValue || 0;
return String(value).padStart(length, "0");
}
// 3. 날짜
case "date": {
const format = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 placeholder 표시
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
// 형식에 맞는 placeholder 반환
switch (format) {
case "YYYY": return "[YYYY]";
case "YY": return "[YY]";
case "YYYYMM": return "[YYYYMM]";
case "YYMM": return "[YYMM]";
case "YYYYMMDD": return "[YYYYMMDD]";
case "YYMMDD": return "[YYMMDD]";
default: return "[DATE]";
}
}
// 현재 날짜 기준 생성
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
switch (format) { switch (format) {
case "YYYY": return String(year); case "YYYY": return "[YYYY]";
case "YY": return String(year).slice(-2); case "YY": return "[YY]";
case "YYYYMM": return `${year}${month}`; case "YYYYMM": return "[YYYYMM]";
case "YYMM": return `${String(year).slice(-2)}${month}`; case "YYMM": return "[YYMM]";
case "YYYYMMDD": return `${year}${month}${day}`; case "YYYYMMDD": return "[YYYYMMDD]";
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; case "YYMMDD": return "[YYMMDD]";
default: return `${year}${month}${day}`; default: return "[DATE]";
} }
} }
const now = new Date();
// 4. 문자 const year = now.getFullYear();
case "text": const month = String(now.getMonth() + 1).padStart(2, "0");
return autoConfig.textValue || "TEXT"; const day = String(now.getDate()).padStart(2, "0");
switch (format) {
default: case "YYYY": return String(year);
return "XXX"; case "YY": return String(year).slice(-2);
case "YYYYMM": return `${year}${month}`;
case "YYMM": return `${String(year).slice(-2)}${month}`;
case "YYYYMMDD": return `${year}${month}${day}`;
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
default: return `${year}${month}${day}`;
}
} }
}); case "text":
return autoConfig.textValue || "TEXT";
default:
return "XXX";
}
});
return parts.join(config.separator || ""); // 파트별 개별 구분자로 결합
const globalSep = config.separator ?? "-";
let result = "";
partValues.forEach((val, idx) => {
result += val;
if (idx < partValues.length - 1) {
const sep = sortedParts[idx].separatorAfter ?? globalSep;
result += sep;
}
});
return result;
}, [config]); }, [config]);
if (compact) { if (compact) {

View File

@ -17,6 +17,7 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
interface EditModalState { interface EditModalState {
isOpen: boolean; isOpen: boolean;
@ -1154,19 +1155,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
if (response.success) { if (response.success) {
const masterRecordId = response.data?.id || formData.id; const masterRecordId = response.data?.id || formData.id;
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId,
mainFormData: formData,
tableName: screenData.screenInfo.tableName,
},
}),
);
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName });
toast.success("데이터가 생성되었습니다."); toast.success("데이터가 생성되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
@ -1214,6 +1202,40 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
} }
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [EditModal] INSERT 후 repeaterSave 완료");
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
handleClose(); handleClose();
} else { } else {
throw new Error(response.message || "생성에 실패했습니다."); throw new Error(response.message || "생성에 실패했습니다.");
@ -1319,6 +1341,40 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
} }
// V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행)
try {
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: recordId,
tableName: screenData.screenInfo.tableName,
mainFormData: formData,
masterRecordId: recordId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료");
} catch (repeaterError) {
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
}
handleClose(); handleClose();
} else { } else {
throw new Error(response.message || "수정에 실패했습니다."); throw new Error(response.message || "수정에 실패했습니다.");
@ -1385,12 +1441,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div> </div>
</div> </div>
) : screenData ? ( ) : screenData ? (
<ScreenContextProvider
screenId={modalState.screenId || undefined}
tableName={screenData.screenInfo?.tableName}
>
<div <div
data-screen-runtime="true" data-screen-runtime="true"
className="relative bg-white" className="relative bg-white"
style={{ style={{
width: screenDimensions?.width || 800, width: screenDimensions?.width || 800,
// 🆕 조건부 레이어가 활성화되면 높이 자동 확장 // 조건부 레이어가 활성화되면 높이 자동 확장
height: (() => { height: (() => {
const baseHeight = (screenDimensions?.height || 600) + 30; const baseHeight = (screenDimensions?.height || 600) + 30;
if (activeConditionalComponents.length > 0) { if (activeConditionalComponents.length > 0) {
@ -1546,6 +1606,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
); );
})} })}
</div> </div>
</ScreenContextProvider>
) : ( ) : (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p> <p className="text-muted-foreground"> .</p>

View File

@ -571,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
return; return;
} }
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
const compType = c.componentType || c.overrides?.type;
if (compType !== "v2-repeater") return false;
const compConfig = c.componentConfig || c.overrides || {};
return !compConfig.useCustomTable;
});
if (hasRepeaterOnSameTable) {
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
try {
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: null,
masterRecordId: null,
mainFormData: formData,
tableName: screenInfo.tableName,
},
}),
);
toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error) {
toast.error("저장 중 오류가 발생했습니다.");
}
return;
}
try { try {
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함 // 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
const masterFormData: Record<string, any> = {}; const masterFormData: Record<string, any> = {};
@ -591,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
Object.entries(formData).forEach(([key, value]) => { Object.entries(formData).forEach(([key, value]) => {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
// 배열이 아닌 값은 그대로 저장
masterFormData[key] = value; masterFormData[key] = value;
} else if (mediaColumnNames.has(key)) { } else if (mediaColumnNames.has(key)) {
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
masterFormData[key] = value.length > 0 ? value[0] : null; masterFormData[key] = value.length > 0 ? value[0] : null;
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`); console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
} else { } else {
@ -608,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
data: masterFormData, data: masterFormData,
}; };
// console.log("💾 저장 액션 실행:", saveData);
const response = await dynamicFormApi.saveData(saveData); const response = await dynamicFormApi.saveData(saveData);
if (response.success) { if (response.success) {
@ -619,7 +645,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
new CustomEvent("repeaterSave", { new CustomEvent("repeaterSave", {
detail: { detail: {
parentId: masterRecordId, parentId: masterRecordId,
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용) masterRecordId,
mainFormData: formData, mainFormData: formData,
tableName: screenInfo.tableName, tableName: screenInfo.tableName,
}, },
@ -631,7 +657,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
toast.error(response.message || "저장에 실패했습니다."); toast.error(response.message || "저장에 실패했습니다.");
} }
} catch (error) { } catch (error) {
// console.error("저장 오류:", error);
toast.error("저장 중 오류가 발생했습니다."); toast.error("저장 중 오류가 발생했습니다.");
} }
}; };

View File

@ -551,9 +551,12 @@ export default function ScreenDesigner({
originalRegion: null, originalRegion: null,
}); });
// 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용) // 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null); const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
// 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용)
const [otherLayerComponents, setOtherLayerComponents] = useState<ComponentData[]>([]);
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기 // 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
useEffect(() => { useEffect(() => {
if (activeLayerId <= 1 || !selectedScreen?.screenId) { if (activeLayerId <= 1 || !selectedScreen?.screenId) {
@ -578,6 +581,41 @@ export default function ScreenDesigner({
findZone(); findZone();
}, [activeLayerId, selectedScreen?.screenId, zones]); }, [activeLayerId, selectedScreen?.screenId, zones]);
// 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용)
useEffect(() => {
if (!selectedScreen?.screenId) return;
const loadOtherLayerComponents = async () => {
try {
const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId);
const currentLayerId = activeLayerIdRef.current || 1;
const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0);
const components: ComponentData[] = [];
for (const layerInfo of otherLayers) {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id);
const rawComps = layerData?.components;
if (rawComps && Array.isArray(rawComps)) {
for (const comp of rawComps) {
components.push({
...comp,
_layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
_layerId: String(layerInfo.layer_id),
} as any);
}
}
} catch {
// 개별 레이어 로드 실패 무시
}
}
setOtherLayerComponents(components);
} catch {
setOtherLayerComponents([]);
}
};
loadOtherLayerComponents();
}, [selectedScreen?.screenId, activeLayerId]);
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
const visibleComponents = useMemo(() => { const visibleComponents = useMemo(() => {
return layout.components; return layout.components;
@ -3968,10 +4006,10 @@ export default function ScreenDesigner({
label: column.columnLabel || column.columnName, label: column.columnLabel || column.columnName,
tableName: table.tableName, tableName: table.tableName,
columnName: column.columnName, columnName: column.columnName,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 required: isEntityJoinColumn ? false : column.required,
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 readonly: false,
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 parentId: formContainerId,
componentType: v2Mapping.componentType, // v2-input, v2-select 등 componentType: v2Mapping.componentType,
position: { x: relativeX, y: relativeY, z: 1 } as Position, position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
@ -3995,12 +4033,11 @@ export default function ScreenDesigner({
}, },
componentConfig: { componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등 type: v2Mapping.componentType, // v2-input, v2-select 등
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 ...v2Mapping.componentConfig,
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
}, },
}; };
} else { } else {
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 return;
} }
} else { } else {
// 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용 // 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용
@ -4036,9 +4073,9 @@ export default function ScreenDesigner({
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName, tableName: table.tableName,
columnName: column.columnName, columnName: column.columnName,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 required: isEntityJoinColumn ? false : column.required,
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 readonly: false,
componentType: v2Mapping.componentType, // v2-input, v2-select 등 componentType: v2Mapping.componentType,
position: { x, y, z: 1 } as Position, position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
@ -4062,8 +4099,7 @@ export default function ScreenDesigner({
}, },
componentConfig: { componentConfig: {
type: v2Mapping.componentType, // v2-input, v2-select 등 type: v2Mapping.componentType, // v2-input, v2-select 등
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 ...v2Mapping.componentConfig,
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
}, },
}; };
} }
@ -6518,8 +6554,8 @@ export default function ScreenDesigner({
updateComponentProperty(selectedComponent.id, "style", style); updateComponentProperty(selectedComponent.id, "style", style);
} }
}} }}
allComponents={layout.components} // 🆕 플로우 위젯 감지용 allComponents={[...layout.components, ...otherLayerComponents]}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 menuObjid={menuObjid}
/> />
)} )}
</TabsContent> </TabsContent>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -92,13 +92,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태 const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
// 🆕 데이터 전달 필드 매핑용 상태 // 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원)
const [mappingSourceColumns, setMappingSourceColumns] = useState<Array<{ name: string; label: string }>>([]); const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState<Record<string, Array<{ name: string; label: string }>>>({});
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]); const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<number, boolean>>({}); const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<string, boolean>>({});
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<number, boolean>>({}); const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<string, boolean>>({});
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({}); const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<string, string>>({});
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({}); const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<string, string>>({});
const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0);
// 🆕 openModalWithData 전용 필드 매핑 상태 // 🆕 openModalWithData 전용 필드 매핑 상태
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]); const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
@ -295,57 +296,57 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
} }
}; };
// 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드 // 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드
useEffect(() => { const loadMappingColumns = useCallback(async (tableName: string): Promise<Array<{ name: string; label: string }>> => {
const sourceTable = config.action?.dataTransfer?.sourceTable; try {
const targetTable = config.action?.dataTransfer?.targetTable; const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
const loadColumns = async () => { if (Array.isArray(columnData)) {
if (sourceTable) { return columnData.map((col: any) => ({
try { name: col.name || col.columnName,
const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
if (response.data.success) { }));
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setMappingSourceColumns(columns);
}
}
} catch (error) {
console.error("소스 테이블 컬럼 로드 실패:", error);
} }
} }
} catch (error) {
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
}
return [];
}, []);
if (targetTable) { useEffect(() => {
try { const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || [];
const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`); const legacySourceTable = config.action?.dataTransfer?.sourceTable;
if (response.data.success) { const targetTable = config.action?.dataTransfer?.targetTable;
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) { const loadAll = async () => {
const columns = columnData.map((col: any) => ({ const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean);
name: col.name || col.columnName, if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) {
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, sourceTableNames.push(legacySourceTable);
})); }
setMappingTargetColumns(columns);
} const newMap: Record<string, Array<{ name: string; label: string }>> = {};
} for (const tbl of sourceTableNames) {
} catch (error) { if (!mappingSourceColumnsMap[tbl]) {
console.error("타겟 테이블 컬럼 로드 실패:", error); newMap[tbl] = await loadMappingColumns(tbl);
} }
} }
if (Object.keys(newMap).length > 0) {
setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap }));
}
if (targetTable && mappingTargetColumns.length === 0) {
const cols = await loadMappingColumns(targetTable);
setMappingTargetColumns(cols);
}
}; };
loadColumns(); loadAll();
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); }, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]);
// 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드 // 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드
useEffect(() => { useEffect(() => {
@ -2966,11 +2967,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" /> <SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{/* 데이터 제공 가능한 컴포넌트 필터링 */} {/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */}
<SelectItem value="__auto__">
<div className="flex items-center gap-2">
<span className="text-xs font-medium"> ( )</span>
<span className="text-muted-foreground text-[10px]">(auto)</span>
</div>
</SelectItem>
{/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */}
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
// 데이터를 제공할 수 있는 컴포넌트 타입들
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
type.includes(t), type.includes(t),
); );
@ -2978,11 +2985,17 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
.map((comp: any) => { .map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown"; const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id; const compLabel = comp.label || comp.componentConfig?.title || comp.id;
const layerName = comp._layerName;
return ( return (
<SelectItem key={comp.id} value={comp.id}> <SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span> <span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span> <span className="text-muted-foreground text-[10px]">({compType})</span>
{layerName && (
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{layerName}
</span>
)}
</div> </div>
</SelectItem> </SelectItem>
); );
@ -2999,7 +3012,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground mt-1 text-xs">, </p> <p className="text-muted-foreground mt-1 text-xs">
"자동 탐색"
</p>
</div> </div>
<div> <div>
@ -3037,33 +3052,47 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</Label> </Label>
<Select <Select
value={config.action?.dataTransfer?.targetComponentId || ""} value={config.action?.dataTransfer?.targetComponentId || ""}
onValueChange={(value) => onValueChange={(value) => {
onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value) onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value);
} // 선택한 컴포넌트가 다른 레이어에 있으면 targetLayerId도 저장
const selectedComp = allComponents.find((c: any) => c.id === value);
if (selectedComp && (selectedComp as any)._layerId) {
onUpdateProperty(
"componentConfig.action.dataTransfer.targetLayerId",
(selectedComp as any)._layerId,
);
} else {
onUpdateProperty("componentConfig.action.dataTransfer.targetLayerId", undefined);
}
}}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" /> <SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{/* 데이터 수신 가능한 컴포넌트 필터링 (소스와 다른 컴포넌트만) */} {/* 데이터 수신 가능한 컴포넌트 필터링 (모든 레이어 포함, 소스와 다른 컴포넌트만) */}
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
// 데이터를 받을 수 있는 컴포넌트 타입들
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t), (t) => type.includes(t),
); );
// 소스와 다른 컴포넌트만
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}) })
.map((comp: any) => { .map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown"; const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id; const compLabel = comp.label || comp.componentConfig?.title || comp.id;
const layerName = comp._layerName;
return ( return (
<SelectItem key={comp.id} value={comp.id}> <SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span> <span className="text-xs font-medium">{compLabel}</span>
<span className="text-muted-foreground text-[10px]">({compType})</span> <span className="text-muted-foreground text-[10px]">({compType})</span>
{layerName && (
<span className="rounded bg-amber-100 px-1 text-[9px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
{layerName}
</span>
)}
</div> </div>
</SelectItem> </SelectItem>
); );
@ -3261,301 +3290,417 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </div>
<div> <div>
<Label htmlFor="additional-field-name" className="text-xs"> <Label htmlFor="additional-field-name" className="text-xs">
() ()
</Label> </Label>
<Input <Popover>
id="additional-field-name" <PopoverTrigger asChild>
placeholder="예: inbound_type (비워두면 전체 데이터)" <Button
value={config.action?.dataTransfer?.additionalSources?.[0]?.fieldName || ""} variant="outline"
onChange={(e) => { role="combobox"
const currentSources = config.action?.dataTransfer?.additionalSources || []; className="h-8 w-full justify-between text-xs"
const newSources = [...currentSources]; >
if (newSources.length === 0) { {(() => {
newSources.push({ componentId: "", fieldName: e.target.value }); const fieldName = config.action?.dataTransfer?.additionalSources?.[0]?.fieldName;
} else { if (!fieldName) return "필드 선택 (비워두면 전체 데이터)";
newSources[0] = { ...newSources[0], fieldName: e.target.value }; const cols = mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns;
} const found = cols.find((c) => c.name === fieldName);
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); return found ? `${found.label || found.name}` : fieldName;
}} })()}
className="h-8 text-xs" <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
/> </Button>
<p className="text-muted-foreground mt-1 text-xs"> </p> </PopoverTrigger>
<PopoverContent className="w-[240px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: "", fieldName: "" });
} else {
newSources[0] = { ...newSources[0], fieldName: "" };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !config.action?.dataTransfer?.additionalSources?.[0]?.fieldName ? "opacity-100" : "opacity-0")} />
<span className="text-muted-foreground"> ( )</span>
</CommandItem>
{(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => (
<CommandItem
key={col.name}
value={`${col.label || ""} ${col.name}`}
onSelect={() => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: "", fieldName: col.name });
} else {
newSources[0] = { ...newSources[0], fieldName: col.name };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", config.action?.dataTransfer?.additionalSources?.[0]?.fieldName === col.name ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{col.label || col.name}</span>
{col.label && col.label !== col.name && (
<span className="text-muted-foreground ml-1 text-[10px]">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div> </div>
</div> </div>
</div> </div>
{/* 필드 매핑 규칙 */} {/* 멀티 테이블 필드 매핑 */}
<div className="space-y-3"> <div className="space-y-3">
<Label> </Label> <Label> </Label>
{/* 소스/타겟 테이블 선택 */} {/* 타겟 테이블 (공통) */}
<div className="grid grid-cols-2 gap-3"> <div className="space-y-1">
<div className="space-y-1"> <Label className="text-xs"> </Label>
<Label className="text-xs"> </Label> <Popover>
<Popover> <PopoverTrigger asChild>
<PopoverTrigger asChild> <Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs"> {config.action?.dataTransfer?.targetTable
{config.action?.dataTransfer?.sourceTable ? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.sourceTable)?.label || config.action?.dataTransfer?.targetTable
config.action?.dataTransfer?.sourceTable : "타겟 테이블 선택"}
: "테이블 선택"} <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> </Button>
</Button> </PopoverTrigger>
</PopoverTrigger> <PopoverContent className="w-[250px] p-0" align="start">
<PopoverContent className="w-[250px] p-0" align="start"> <Command>
<Command> <CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" /> <CommandList>
<CommandList> <CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty> <CommandGroup>
<CommandGroup> {availableTables.map((table) => (
{availableTables.map((table) => ( <CommandItem
<CommandItem key={table.name}
key={table.name} value={`${table.label} ${table.name}`}
value={`${table.label} ${table.name}`} onSelect={() => {
onSelect={() => { onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name); }}
}} className="text-xs"
className="text-xs" >
> <Check
<Check className={cn(
className={cn( "mr-2 h-3 w-3",
"mr-2 h-3 w-3", config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0", )}
)} />
/> <span className="font-medium">{table.label}</span>
<span className="font-medium">{table.label}</span> <span className="text-muted-foreground ml-1">({table.name})</span>
<span className="text-muted-foreground ml-1">({table.name})</span> </CommandItem>
</CommandItem> ))}
))} </CommandGroup>
</CommandGroup> </CommandList>
</CommandList> </Command>
</Command> </PopoverContent>
</PopoverContent> </Popover>
</Popover>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{config.action?.dataTransfer?.targetTable
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
config.action?.dataTransfer?.targetTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div> </div>
{/* 필드 매핑 규칙 */} {/* 소스 테이블 매핑 그룹 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="h-6 text-[10px]" className="h-6 text-[10px]"
onClick={() => { onClick={() => {
const currentRules = config.action?.dataTransfer?.mappingRules || []; const currentMappings = config.action?.dataTransfer?.multiTableMappings || [];
const newRule = { sourceField: "", targetField: "", transform: "" }; onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", [
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", [...currentRules, newRule]); ...currentMappings,
{ sourceTable: "", mappingRules: [] },
]);
setActiveMappingGroupIndex(currentMappings.length);
}} }}
disabled={!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable} disabled={!config.action?.dataTransfer?.targetTable}
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
</Button> </Button>
</div> </div>
<p className="text-muted-foreground text-[10px]"> <p className="text-muted-foreground text-[10px]">
. . , . .
</p> </p>
{!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? ( {!config.action?.dataTransfer?.targetTable ? (
<div className="rounded-md border border-dashed p-3 text-center"> <div className="rounded-md border border-dashed p-3 text-center">
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? ( ) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (
<div className="rounded-md border border-dashed p-3 text-center"> <div className="rounded-md border border-dashed p-3 text-center">
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
. . . .
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => ( {/* 소스 테이블 탭 */}
<div key={index} className="bg-background flex items-center gap-2 rounded-md border p-2"> <div className="flex flex-wrap gap-1">
{/* 소스 필드 선택 (Combobox) */} {(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => (
<div className="flex-1"> <div key={gIdx} className="flex items-center gap-0.5">
<Popover <Button
open={mappingSourcePopoverOpen[index] || false} type="button"
onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} variant={activeMappingGroupIndex === gIdx ? "default" : "outline"}
size="sm"
className="h-6 text-[10px]"
onClick={() => setActiveMappingGroupIndex(gIdx)}
> >
<PopoverTrigger asChild> {group.sourceTable
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs"> ? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable
{rule.sourceField : `그룹 ${gIdx + 1}`}
? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label || {group.mappingRules?.length > 0 && (
rule.sourceField <span className="bg-primary/20 ml-1 rounded-full px-1 text-[9px]">
: "소스 필드"} {group.mappingRules.length}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" /> </span>
</Button> )}
</PopoverTrigger> </Button>
<PopoverContent className="w-[200px] p-0" align="start"> <Button
<Command> type="button"
<CommandInput variant="ghost"
placeholder="컬럼 검색..." size="icon"
className="h-8 text-xs" className="text-destructive hover:bg-destructive/10 h-5 w-5"
value={mappingSourceSearch[index] || ""} onClick={() => {
onValueChange={(value) => const mappings = [...(config.action?.dataTransfer?.multiTableMappings || [])];
setMappingSourceSearch((prev) => ({ ...prev, [index]: value })) mappings.splice(gIdx, 1);
} onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
/> if (activeMappingGroupIndex >= mappings.length) {
<CommandList> setActiveMappingGroupIndex(Math.max(0, mappings.length - 1));
<CommandEmpty className="py-2 text-center text-xs"> }
}}
</CommandEmpty>
<CommandGroup>
{mappingSourceColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
rules[index] = { ...rules[index], sourceField: col.name };
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
rule.sourceField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="text-muted-foreground ml-1">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<span className="text-muted-foreground text-xs"></span>
{/* 타겟 필드 선택 (Combobox) */}
<div className="flex-1">
<Popover
open={mappingTargetPopoverOpen[index] || false}
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
> >
<PopoverTrigger asChild> <X className="h-3 w-3" />
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs"> </Button>
{rule.targetField
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label ||
rule.targetField
: "타겟 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={mappingTargetSearch[index] || ""}
onValueChange={(value) =>
setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))
}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
</CommandEmpty>
<CommandGroup>
{mappingTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
rules[index] = { ...rules[index], targetField: col.name };
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
rule.targetField === col.name ? "opacity-100" : "opacity-0",
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="text-muted-foreground ml-1">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div> </div>
))}
</div>
<Button {/* 활성 그룹 편집 영역 */}
type="button" {(() => {
variant="ghost" const multiMappings = config.action?.dataTransfer?.multiTableMappings || [];
size="icon" const activeGroup = multiMappings[activeMappingGroupIndex];
className="text-destructive hover:bg-destructive/10 h-7 w-7" if (!activeGroup) return null;
onClick={() => {
const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; const activeSourceTable = activeGroup.sourceTable || "";
rules.splice(index, 1); const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || [];
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); const activeRules: any[] = activeGroup.mappingRules || [];
}}
> const updateGroupField = (field: string, value: any) => {
<X className="h-3 w-3" /> const mappings = [...multiMappings];
</Button> mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value };
</div> onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings);
))} };
return (
<div className="space-y-2 rounded-md border p-3">
{/* 소스 테이블 선택 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs">
{activeSourceTable
? availableTables.find((t) => t.name === activeSourceTable)?.label || activeSourceTable
: "소스 테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={async () => {
updateGroupField("sourceTable", table.name);
if (!mappingSourceColumnsMap[table.name]) {
const cols = await loadMappingColumns(table.name);
setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols }));
}
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
activeSourceTable === table.name ? "opacity-100" : "opacity-0",
)}
/>
<span className="font-medium">{table.label}</span>
<span className="text-muted-foreground ml-1">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 매핑 규칙 목록 */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-5 text-[10px]"
onClick={() => {
updateGroupField("mappingRules", [...activeRules, { sourceField: "", targetField: "" }]);
}}
disabled={!activeSourceTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{!activeSourceTable ? (
<p className="text-muted-foreground text-[10px]"> .</p>
) : activeRules.length === 0 ? (
<p className="text-muted-foreground text-[10px]"> ( )</p>
) : (
activeRules.map((rule: any, rIdx: number) => {
const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`;
const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`;
return (
<div key={rIdx} className="bg-background flex items-center gap-2 rounded-md border p-2">
<div className="flex-1">
<Popover
open={mappingSourcePopoverOpen[popoverKeyS] || false}
onOpenChange={(open) =>
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{rule.sourceField
? activeSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
: "소스 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{activeSourceColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name };
updateGroupField("mappingRules", newRules);
setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", rule.sourceField === col.name ? "opacity-100" : "opacity-0")} />
<span>{col.label}</span>
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<span className="text-muted-foreground text-xs"></span>
<div className="flex-1">
<Popover
open={mappingTargetPopoverOpen[popoverKeyT] || false}
onOpenChange={(open) =>
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open }))
}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-7 w-full justify-between text-xs">
{rule.targetField
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
: "타겟 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{mappingTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const newRules = [...activeRules];
newRules[rIdx] = { ...newRules[rIdx], targetField: col.name };
updateGroupField("mappingRules", newRules);
setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", rule.targetField === col.name ? "opacity-100" : "opacity-0")} />
<span>{col.label}</span>
{col.label !== col.name && <span className="text-muted-foreground ml-1">({col.name})</span>}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-7 w-7"
onClick={() => {
const newRules = [...activeRules];
newRules.splice(rIdx, 1);
updateGroupField("mappingRules", newRules);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})
)}
</div>
</div>
);
})()}
</div> </div>
)} )}
</div> </div>
@ -3567,9 +3712,9 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<br /> <br />
1. 1.
<br /> <br />
2. (: 품번 ) 2.
<br /> <br />
3. 3.
</p> </p>
</div> </div>
</div> </div>

View File

@ -217,6 +217,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel, "v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel, "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel, "v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
"v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel,
}; };
const V2ConfigPanel = v2ConfigPanels[componentId]; const V2ConfigPanel = v2ConfigPanels[componentId];
@ -236,11 +237,13 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
const extraProps: Record<string, any> = {}; const extraProps: Record<string, any> = {};
if (componentId === "v2-select") { if (componentId === "v2-select") {
extraProps.inputType = inputType; extraProps.inputType = inputType;
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
} }
if (componentId === "v2-list") { if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName; extraProps.currentTableName = currentTableName;
} }
if (componentId === "v2-bom-item-editor") { if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
extraProps.currentTableName = currentTableName; extraProps.currentTableName = currentTableName;
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
} }

View File

@ -72,9 +72,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
allColumns = response.data; allColumns = response.data;
} }
// category 타입 컬럼만 필터링 // category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외)
const categoryColumns = allColumns.filter( const categoryColumns = allColumns.filter(
(col: any) => col.inputType === "category" || col.input_type === "category" (col: any) => (col.inputType === "category" || col.input_type === "category")
&& !col.categoryRef && !col.category_ref
); );
console.log("✅ 카테고리 컬럼 필터링 완료:", { console.log("✅ 카테고리 컬럼 필터링 완료:", {

View File

@ -23,6 +23,9 @@ import {
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { allocateNumberingCode } from "@/lib/api/numberingRule"; import { allocateNumberingCode } from "@/lib/api/numberingRule";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { DataReceivable } from "@/types/data-transfer";
import { toast } from "sonner";
// modal-repeater-table 컴포넌트 재사용 // modal-repeater-table 컴포넌트 재사용
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable"; import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
@ -38,6 +41,7 @@ declare global {
export const V2Repeater: React.FC<V2RepeaterProps> = ({ export const V2Repeater: React.FC<V2RepeaterProps> = ({
config: propConfig, config: propConfig,
componentId,
parentId, parentId,
data: initialData, data: initialData,
onDataChange, onDataChange,
@ -48,6 +52,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}) => { }) => {
// ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용) // ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용)
const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData; const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData;
// componentId 결정: 직접 전달 또는 component 객체에서 추출
const effectiveComponentId = componentId || (restProps as any).component?.id;
// ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null)
const screenContext = useScreenContextOptional();
// 설정 병합 // 설정 병합
const config: V2RepeaterConfig = useMemo( const config: V2RepeaterConfig = useMemo(
() => ({ () => ({
@ -65,9 +75,119 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set()); const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
// 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref
const dataRef = useRef<any[]>(data);
useEffect(() => {
dataRef.current = data;
}, [data]);
// 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용)
const loadedIdsRef = useRef<Set<string>>(new Set());
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
// ScreenContext DataReceiver 등록 (데이터 전달 액션 수신)
const onDataChangeRef = useRef(onDataChange);
onDataChangeRef.current = onDataChange;
const handleReceiveData = useCallback(
async (incomingData: any[], configOrMode?: any) => {
console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
if (!incomingData || incomingData.length === 0) {
toast.warning("전달할 데이터가 없습니다");
return;
}
// 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
const metaFieldsToStrip = new Set([
"id",
"created_date",
"updated_date",
"created_by",
"updated_by",
"company_code",
]);
const normalizedData = incomingData.map((item: any) => {
let raw = item;
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
const { 0: originalData, ...additionalFields } = item;
raw = { ...originalData, ...additionalFields };
}
const cleaned: Record<string, any> = {};
for (const [key, value] of Object.entries(raw)) {
if (!metaFieldsToStrip.has(key)) {
cleaned[key] = value;
}
}
return cleaned;
});
const mode = configOrMode?.mode || configOrMode || "append";
// 카테고리 코드 → 라벨 변환
// allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환
const codesToResolve = new Set<string>();
for (const item of normalizedData) {
for (const [key, val] of Object.entries(item)) {
if (key.startsWith("_")) continue;
if (typeof val === "string" && val && !categoryLabelMapRef.current[val]) {
codesToResolve.add(val as string);
}
}
}
if (codesToResolve.size > 0) {
try {
const resp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(codesToResolve),
});
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
for (const item of normalizedData) {
for (const key of Object.keys(item)) {
if (key.startsWith("_")) continue;
const val = item[key];
if (typeof val === "string" && labelData[val]) {
item[key] = labelData[val];
}
}
}
}
} catch {
// 변환 실패 시 코드 유지
}
}
setData((prev) => {
const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData];
onDataChangeRef.current?.(next);
return next;
});
toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
},
[],
);
useEffect(() => {
if (screenContext && effectiveComponentId) {
const receiver: DataReceivable = {
componentId: effectiveComponentId,
componentType: "v2-repeater",
receiveData: handleReceiveData,
};
console.log("📋 [V2Repeater] ScreenContext에 데이터 수신자 등록:", effectiveComponentId);
screenContext.registerDataReceiver(effectiveComponentId, receiver);
return () => {
screenContext.unregisterDataReceiver(effectiveComponentId);
};
}
}, [screenContext, effectiveComponentId, handleReceiveData]);
// 소스 테이블 컬럼 라벨 매핑 // 소스 테이블 컬럼 라벨 매핑
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({}); const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
@ -76,6 +196,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({}); const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
const categoryLabelMapRef = useRef<Record<string, string>>({});
useEffect(() => {
categoryLabelMapRef.current = categoryLabelMap;
}, [categoryLabelMap]);
// 현재 테이블 컬럼 정보 (inputType 매핑용) // 현재 테이블 컬럼 정보 (inputType 매핑용)
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({}); const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
@ -109,35 +233,54 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}; };
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
// 저장 이벤트 리스너 // 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조)
useEffect(() => { useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => { const handleSaveEvent = async (event: CustomEvent) => {
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용 const currentData = dataRef.current;
const tableName = const currentCategoryMap = categoryLabelMapRef.current;
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
const eventParentId = event.detail?.parentId;
const mainFormData = event.detail?.mainFormData;
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용) const configTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
const tableName = configTableName || event.detail?.tableName;
const mainFormData = event.detail?.mainFormData;
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id; const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
if (!tableName || data.length === 0) { console.log("🔵 [V2Repeater] repeaterSave 이벤트 수신:", {
configTableName,
tableName,
masterRecordId,
dataLength: currentData.length,
foreignKeyColumn: config.foreignKeyColumn,
foreignKeySourceColumn: config.foreignKeySourceColumn,
dataSnapshot: currentData.map((r: any) => ({ id: r.id, item_name: r.item_name })),
});
toast.info(`[디버그] V2Repeater 이벤트 수신: ${currentData.length}건, table=${tableName}`);
if (!tableName || currentData.length === 0) {
console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length });
toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`);
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
return; return;
} }
// V2Repeater 저장 시작 if (config.foreignKeyColumn) {
const saveInfo = { const sourceCol = config.foreignKeySourceColumn;
const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined;
if (!hasFkSource && !masterRecordId) {
console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵");
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
return;
}
}
console.log("V2Repeater 저장 시작", {
tableName, tableName,
useCustomTable: config.useCustomTable,
mainTableName: config.mainTableName,
foreignKeyColumn: config.foreignKeyColumn, foreignKeyColumn: config.foreignKeyColumn,
masterRecordId, masterRecordId,
dataLength: data.length, dataLength: currentData.length,
}; });
console.log("V2Repeater 저장 시작", saveInfo);
try { try {
// 테이블 유효 컬럼 조회
let validColumns: Set<string> = new Set(); let validColumns: Set<string> = new Set();
try { try {
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
@ -148,13 +291,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
console.warn("테이블 컬럼 정보 조회 실패"); console.warn("테이블 컬럼 정보 조회 실패");
} }
for (let i = 0; i < data.length; i++) { for (let i = 0; i < currentData.length; i++) {
const row = data[i]; const row = currentData[i];
// 내부 필드 제거
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
let mergedData: Record<string, any>; let mergedData: Record<string, any>;
if (config.useCustomTable && config.mainTableName) { if (config.useCustomTable && config.mainTableName) {
mergedData = { ...cleanRow }; mergedData = { ...cleanRow };
@ -181,59 +321,83 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}; };
} }
// 유효하지 않은 컬럼 제거
const filteredData: Record<string, any> = {}; const filteredData: Record<string, any> = {};
for (const [key, value] of Object.entries(mergedData)) { for (const [key, value] of Object.entries(mergedData)) {
if (validColumns.size === 0 || validColumns.has(key)) { if (validColumns.size === 0 || validColumns.has(key)) {
filteredData[key] = value; if (typeof value === "string" && currentCategoryMap[value]) {
filteredData[key] = currentCategoryMap[value];
} else {
filteredData[key] = value;
}
} }
} }
// 기존 행(id 존재)은 UPDATE, 새 행은 INSERT
const rowId = row.id; const rowId = row.id;
console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, {
rowId,
isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"),
filteredDataKeys: Object.keys(filteredData),
});
if (rowId && typeof rowId === "string" && rowId.includes("-")) { if (rowId && typeof rowId === "string" && rowId.includes("-")) {
// UUID 형태의 id가 있으면 기존 데이터 → UPDATE
const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData; const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData;
await apiClient.put(`/table-management/tables/${tableName}/edit`, { await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData: { id: rowId }, originalData: { id: rowId },
updatedData: updateFields, updatedData: updateFields,
}); });
} else { } else {
// 새 행 → INSERT
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
} }
} }
// 삭제된 행 처리: 원본에는 있었지만 현재 data에 없는 ID를 DELETE
const currentIds = new Set(currentData.map((r) => r.id).filter(Boolean));
const deletedIds = Array.from(loadedIdsRef.current).filter((id) => !currentIds.has(id));
if (deletedIds.length > 0) {
console.log("🗑️ [V2Repeater] 삭제할 행:", deletedIds);
try {
await apiClient.delete(`/table-management/tables/${tableName}/delete`, {
data: deletedIds.map((id) => ({ id })),
});
console.log(`✅ [V2Repeater] ${deletedIds.length}건 삭제 완료`);
} catch (deleteError) {
console.error("❌ [V2Repeater] 삭제 실패:", deleteError);
}
}
// 저장 완료 후 loadedIdsRef 갱신
loadedIdsRef.current = new Set(currentData.map((r) => r.id).filter(Boolean));
toast.success(`V2Repeater ${currentData.length}건 저장 완료`);
} catch (error) { } catch (error) {
console.error("❌ V2Repeater 저장 실패:", error); console.error("❌ V2Repeater 저장 실패:", error);
throw error; toast.error(`V2Repeater 저장 실패: ${error}`);
} finally {
window.dispatchEvent(new CustomEvent("repeaterSaveComplete"));
} }
}; };
// V2 EventBus 구독
const unsubscribe = v2EventBus.subscribe( const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.REPEATER_SAVE, V2_EVENTS.REPEATER_SAVE,
async (payload) => { async (payload) => {
const tableName = const configTableName =
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
if (payload.tableName === tableName) { if (!configTableName || payload.tableName === configTableName) {
await handleSaveEvent({ detail: payload } as CustomEvent); await handleSaveEvent({ detail: payload } as CustomEvent);
} }
}, },
{ componentId: `v2-repeater-${config.dataSource?.tableName}` }, { componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` },
); );
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
window.addEventListener("repeaterSave" as any, handleSaveEvent); window.addEventListener("repeaterSave" as any, handleSaveEvent);
return () => { return () => {
unsubscribe(); unsubscribe();
window.removeEventListener("repeaterSave" as any, handleSaveEvent); window.removeEventListener("repeaterSave" as any, handleSaveEvent);
}; };
}, [ }, [
data,
config.dataSource?.tableName, config.dataSource?.tableName,
config.useCustomTable, config.useCustomTable,
config.mainTableName, config.mainTableName,
config.foreignKeyColumn, config.foreignKeyColumn,
config.foreignKeySourceColumn,
parentId, parentId,
]); ]);
@ -301,7 +465,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}); });
// 각 행에 소스 테이블의 표시 데이터 병합 // 각 행에 소스 테이블의 표시 데이터 병합
// RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함
rows.forEach((row: any) => { rows.forEach((row: any) => {
const sourceRecord = sourceMap.get(String(row[fkColumn])); const sourceRecord = sourceMap.get(String(row[fkColumn]));
if (sourceRecord) { if (sourceRecord) {
@ -319,12 +482,50 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} }
} }
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
const codesToResolve = new Set<string>();
for (const row of rows) {
for (const val of Object.values(row)) {
if (typeof val === "string" && val.startsWith("CATEGORY_")) {
codesToResolve.add(val);
}
}
}
if (codesToResolve.size > 0) {
try {
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(codesToResolve),
});
if (labelResp.data?.success && labelResp.data.data) {
const labelData = labelResp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
for (const row of rows) {
for (const key of Object.keys(row)) {
if (key.startsWith("_")) continue;
const val = row[key];
if (typeof val === "string" && labelData[val]) {
row[key] = labelData[val];
}
}
}
}
} catch {
// 라벨 변환 실패 시 코드 유지
}
}
// 원본 ID 목록 기록 (삭제 추적용)
const ids = rows.map((r: any) => r.id).filter(Boolean);
loadedIdsRef.current = new Set(ids);
console.log("📋 [V2Repeater] 원본 ID 기록:", ids);
setData(rows); setData(rows);
dataLoadedRef.current = true; dataLoadedRef.current = true;
if (onDataChange) onDataChange(rows); if (onDataChange) onDataChange(rows);
} }
} catch (error) { } catch (error) {
console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error); console.error("[V2Repeater] 기존 데이터 로드 실패:", error);
} }
}; };
@ -346,16 +547,28 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
if (!tableName) return; if (!tableName) return;
try { try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); const [colResponse, typeResponse] = await Promise.all([
const columns = response.data?.data?.columns || response.data?.columns || response.data || []; apiClient.get(`/table-management/tables/${tableName}/columns`),
apiClient.get(`/table-management/tables/${tableName}/web-types`),
]);
const columns = colResponse.data?.data?.columns || colResponse.data?.columns || colResponse.data || [];
const inputTypes = typeResponse.data?.data || [];
// inputType/categoryRef 매핑 생성
const typeMap: Record<string, any> = {};
inputTypes.forEach((t: any) => {
typeMap[t.columnName] = t;
});
const columnMap: Record<string, any> = {}; const columnMap: Record<string, any> = {};
columns.forEach((col: any) => { columns.forEach((col: any) => {
const name = col.columnName || col.column_name || col.name; const name = col.columnName || col.column_name || col.name;
const typeInfo = typeMap[name];
columnMap[name] = { columnMap[name] = {
inputType: col.inputType || col.input_type || col.webType || "text", inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text",
displayName: col.displayName || col.display_name || col.label || name, displayName: col.displayName || col.display_name || col.label || name,
detailSettings: col.detailSettings || col.detail_settings, detailSettings: col.detailSettings || col.detail_settings,
categoryRef: typeInfo?.categoryRef || null,
}; };
}); });
setCurrentTableColumnInfo(columnMap); setCurrentTableColumnInfo(columnMap);
@ -487,14 +700,18 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
else if (inputType === "code") type = "select"; else if (inputType === "code") type = "select";
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) // 카테고리 참조 ID 결정
// category 타입인 경우 현재 테이블명과 컬럼명을 조합 // DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용
let categoryRef: string | undefined; let categoryRef: string | undefined;
if (inputType === "category") { if (inputType === "category") {
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용 const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef;
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; if (dbCategoryRef) {
if (tableName) { categoryRef = dbCategoryRef;
categoryRef = `${tableName}.${col.key}`; } else {
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
if (tableName) {
categoryRef = `${tableName}.${col.key}`;
}
} }
} }
@ -512,55 +729,79 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}); });
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) // 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지
// repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영)
const allCategoryColumns = useMemo(() => {
const fromRepeater = repeaterColumns
.filter((col) => col.type === "category")
.map((col) => col.field.replace(/^_display_/, ""));
const merged = new Set([...sourceCategoryColumns, ...fromRepeater]);
return Array.from(merged);
}, [sourceCategoryColumns, repeaterColumns]);
// CATEGORY_ 코드 배열을 받아 라벨을 일괄 조회하는 함수
const fetchCategoryLabels = useCallback(async (codes: string[]) => {
if (codes.length === 0) return;
try {
const response = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: codes,
});
if (response.data?.success && response.data.data) {
setCategoryLabelMap((prev) => ({ ...prev, ...response.data.data }));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
}, []);
// parentFormData(마스터 행)에서 카테고리 코드를 미리 로드
// fromMainForm autoFill에서 참조할 마스터 필드의 라벨을 사전에 확보
useEffect(() => { useEffect(() => {
const loadCategoryLabels = async () => { if (!parentFormData) return;
if (sourceCategoryColumns.length === 0 || data.length === 0) { const codes: string[] = [];
return;
}
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집 // fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집
const allCodes = new Set<string>(); for (const col of config.columns) {
for (const row of data) { if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) {
for (const col of sourceCategoryColumns) { const val = parentFormData[col.autoFill.sourceField];
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인 if (typeof val === "string" && val && !categoryLabelMap[val]) {
const val = row[`_display_${col}`] || row[col]; codes.push(val);
if (val && typeof val === "string") {
const codes = val
.split(",")
.map((c: string) => c.trim())
.filter(Boolean);
for (const code of codes) {
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
allCodes.add(code);
}
}
}
} }
} }
// receiveFromParent 패턴
if (allCodes.size === 0) { if ((col as any).receiveFromParent) {
return; const parentField = (col as any).parentFieldName || col.key;
} const val = parentFormData[parentField];
if (typeof val === "string" && val && !categoryLabelMap[val]) {
try { codes.push(val);
const response = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(allCodes),
});
if (response.data?.success && response.data.data) {
setCategoryLabelMap((prev) => ({
...prev,
...response.data.data,
}));
} }
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
} }
}; }
loadCategoryLabels(); if (codes.length > 0) {
}, [data, sourceCategoryColumns]); fetchCategoryLabels(codes);
}
}, [parentFormData, config.columns, fetchCategoryLabels]);
// 데이터 변경 시 카테고리 라벨 로드
useEffect(() => {
if (data.length === 0) return;
const allCodes = new Set<string>();
for (const row of data) {
for (const col of allCategoryColumns) {
const val = row[`_display_${col}`] || row[col];
if (val && typeof val === "string") {
val.split(",").map((c: string) => c.trim()).filter(Boolean).forEach((code: string) => {
if (!categoryLabelMap[code]) allCodes.add(code);
});
}
}
}
fetchCategoryLabels(Array.from(allCodes));
}, [data, allCategoryColumns, fetchCategoryLabels]);
// 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능) // 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능)
const applyCalculationRules = useCallback( const applyCalculationRules = useCallback(
@ -677,7 +918,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
case "fromMainForm": case "fromMainForm":
if (col.autoFill.sourceField && mainFormData) { if (col.autoFill.sourceField && mainFormData) {
return mainFormData[col.autoFill.sourceField]; const rawValue = mainFormData[col.autoFill.sourceField];
// categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관)
if (typeof rawValue === "string" && categoryLabelMap[rawValue]) {
return categoryLabelMap[rawValue];
}
return rawValue;
} }
return ""; return "";
@ -697,7 +943,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
return undefined; return undefined;
} }
}, },
[], [categoryLabelMap],
); );
// 🆕 채번 API 호출 (비동기) // 🆕 채번 API 호출 (비동기)
@ -731,7 +977,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const row: any = { _id: `grouped_${Date.now()}_${index}` }; const row: any = { _id: `grouped_${Date.now()}_${index}` };
for (const col of config.columns) { for (const col of config.columns) {
const sourceValue = item[(col as any).sourceKey || col.key]; let sourceValue = item[(col as any).sourceKey || col.key];
// 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반)
if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) {
sourceValue = categoryLabelMap[sourceValue];
}
if (col.isSourceDisplay) { if (col.isSourceDisplay) {
row[col.key] = sourceValue ?? ""; row[col.key] = sourceValue ?? "";
@ -752,6 +1003,48 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
return row; return row;
}); });
// 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관)
const categoryColSet = new Set(allCategoryColumns);
const codesToResolve = new Set<string>();
for (const row of newRows) {
for (const col of config.columns) {
const val = row[col.key] || row[`_display_${col.key}`];
if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) {
if (!categoryLabelMap[val]) {
codesToResolve.add(val);
}
}
}
}
if (codesToResolve.size > 0) {
apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(codesToResolve),
}).then((resp) => {
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
const convertedRows = newRows.map((row) => {
const updated = { ...row };
for (const col of config.columns) {
const val = updated[col.key];
if (typeof val === "string" && labelData[val]) {
updated[col.key] = labelData[val];
}
const dispKey = `_display_${col.key}`;
const dispVal = updated[dispKey];
if (typeof dispVal === "string" && labelData[dispVal]) {
updated[dispKey] = labelData[dispVal];
}
}
return updated;
});
setData(convertedRows);
onDataChange?.(convertedRows);
}
}).catch(() => {});
}
setData(newRows); setData(newRows);
onDataChange?.(newRows); onDataChange?.(newRows);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -786,7 +1079,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentFormData, config.columns, generateAutoFillValueSync]); }, [parentFormData, config.columns, generateAutoFillValueSync]);
// 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 // 행 추가 (inline 모드 또는 모달 열기)
const handleAddRow = useCallback(async () => { const handleAddRow = useCallback(async () => {
if (isModalMode) { if (isModalMode) {
setModalOpen(true); setModalOpen(true);
@ -794,11 +1087,10 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
const newRow: any = { _id: `new_${Date.now()}` }; const newRow: any = { _id: `new_${Date.now()}` };
const currentRowCount = data.length; const currentRowCount = data.length;
// 먼저 동기적 자동 입력 값 적용 // 동기적 자동 입력 값 적용
for (const col of config.columns) { for (const col of config.columns) {
const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData); const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData);
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) { if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
// 채번 규칙: 즉시 API 호출
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId); newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
} else if (autoValue !== undefined) { } else if (autoValue !== undefined) {
newRow[col.key] = autoValue; newRow[col.key] = autoValue;
@ -807,10 +1099,51 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
} }
} }
// fromMainForm 등으로 넘어온 카테고리 코드 → 라벨 변환
// allCategoryColumns에 해당하는 컬럼이거나 categoryLabelMap에 매핑이 있으면 변환
const categoryColSet = new Set(allCategoryColumns);
const unresolvedCodes: string[] = [];
for (const col of config.columns) {
const val = newRow[col.key];
if (typeof val !== "string" || !val) continue;
// 이 컬럼이 카테고리 타입이거나, fromMainForm으로 가져온 값인 경우
const isCategoryCol = categoryColSet.has(col.key);
const isFromMainForm = col.autoFill?.type === "fromMainForm";
if (isCategoryCol || isFromMainForm) {
if (categoryLabelMap[val]) {
newRow[col.key] = categoryLabelMap[val];
} else {
unresolvedCodes.push(val);
}
}
}
if (unresolvedCodes.length > 0) {
try {
const resp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: unresolvedCodes,
});
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
for (const col of config.columns) {
const val = newRow[col.key];
if (typeof val === "string" && labelData[val]) {
newRow[col.key] = labelData[val];
}
}
}
} catch {
// 변환 실패 시 코드 유지
}
}
const newData = [...data, newRow]; const newData = [...data, newRow];
handleDataChange(newData); handleDataChange(newData);
} }
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]); }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]);
// 모달에서 항목 선택 - 비동기로 변경 // 모달에서 항목 선택 - 비동기로 변경
const handleSelectItems = useCallback( const handleSelectItems = useCallback(
@ -835,8 +1168,12 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
// 모든 컬럼 처리 (순서대로) // 모든 컬럼 처리 (순서대로)
for (const col of config.columns) { for (const col of config.columns) {
if (col.isSourceDisplay) { if (col.isSourceDisplay) {
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용) let displayVal = item[col.key] || "";
row[`_display_${col.key}`] = item[col.key] || ""; // 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관)
if (typeof displayVal === "string" && categoryLabelMap[displayVal]) {
displayVal = categoryLabelMap[displayVal];
}
row[`_display_${col.key}`] = displayVal;
} else { } else {
// 자동 입력 값 적용 // 자동 입력 값 적용
const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData); const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData);
@ -856,6 +1193,43 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}), }),
); );
// 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환
const categoryColSet = new Set(allCategoryColumns);
const unresolvedCodes = new Set<string>();
for (const row of newRows) {
for (const col of config.columns) {
const val = row[col.key];
if (typeof val !== "string" || !val) continue;
const isCategoryCol = categoryColSet.has(col.key);
const isFromMainForm = col.autoFill?.type === "fromMainForm";
if ((isCategoryCol || isFromMainForm) && !categoryLabelMap[val]) {
unresolvedCodes.add(val);
}
}
}
if (unresolvedCodes.size > 0) {
try {
const resp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(unresolvedCodes),
});
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
setCategoryLabelMap((prev) => ({ ...prev, ...labelData }));
for (const row of newRows) {
for (const col of config.columns) {
const val = row[col.key];
if (typeof val === "string" && labelData[val]) {
row[col.key] = labelData[val];
}
}
}
}
} catch {
// 변환 실패 시 코드 유지
}
}
const newData = [...data, ...newRows]; const newData = [...data, ...newRows];
handleDataChange(newData); handleDataChange(newData);
setModalOpen(false); setModalOpen(false);
@ -869,6 +1243,8 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
generateAutoFillValueSync, generateAutoFillValueSync,
generateNumberingCode, generateNumberingCode,
parentFormData, parentFormData,
categoryLabelMap,
allCategoryColumns,
], ],
); );
@ -881,9 +1257,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}, [config.columns]); }, [config.columns]);
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환 // 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
const dataRef = useRef(data);
dataRef.current = data;
useEffect(() => { useEffect(() => {
const handleBeforeFormSave = async (event: Event) => { const handleBeforeFormSave = async (event: Event) => {
const customEvent = event as CustomEvent; const customEvent = event as CustomEvent;
@ -1112,7 +1485,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
selectedRows={selectedRows} selectedRows={selectedRows}
onSelectionChange={setSelectedRows} onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={autoWidthTrigger} equalizeWidthsTrigger={autoWidthTrigger}
categoryColumns={sourceCategoryColumns} categoryColumns={allCategoryColumns}
categoryLabelMap={categoryLabelMap} categoryLabelMap={categoryLabelMap}
/> />
</div> </div>

View File

@ -622,6 +622,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
config: configProp, config: configProp,
value, value,
onChange, onChange,
onFormDataChange,
tableName, tableName,
columnName, columnName,
isDesignMode, // 🔧 디자인 모드 (클릭 방지) isDesignMode, // 🔧 디자인 모드 (클릭 방지)
@ -630,6 +631,9 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
// config가 없으면 기본값 사용 // config가 없으면 기본값 사용
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] }; const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
// 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지
const allComponents = (props as any).allComponents as any[] | undefined;
const [options, setOptions] = useState<SelectOption[]>(config.options || []); const [options, setOptions] = useState<SelectOption[]>(config.options || []);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false);
@ -742,10 +746,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const valueCol = entityValueColumn || "id"; const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name"; const labelCol = entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${entityTable}/options`, { const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: { params: { value: valueCol, label: labelCol },
value: valueCol,
label: labelCol,
},
}); });
const data = response.data; const data = response.data;
if (data.success && data.data) { if (data.success && data.data) {
@ -819,6 +820,70 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
loadOptions(); loadOptions();
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]);
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
const autoFillTargets = useMemo(() => {
if (source !== "entity" || !entityTable || !allComponents) return [];
const targets: Array<{ sourceField: string; targetColumnName: string }> = [];
for (const comp of allComponents) {
if (comp.id === id) continue;
// overrides 구조 지원 (DB에서 로드 시 overrides 안에 데이터가 있음)
const ov = (comp as any).overrides || {};
const compColumnName = comp.columnName || ov.columnName || comp.componentConfig?.columnName || "";
// 방법1: entityJoinTable 속성이 있는 경우
const joinTable = comp.entityJoinTable || ov.entityJoinTable || comp.componentConfig?.entityJoinTable;
const joinColumn = comp.entityJoinColumn || ov.entityJoinColumn || comp.componentConfig?.entityJoinColumn;
if (joinTable === entityTable && joinColumn) {
targets.push({ sourceField: joinColumn, targetColumnName: compColumnName });
continue;
}
// 방법2: columnName이 "테이블명.컬럼명" 형식인 경우 (예: item_info.unit)
if (compColumnName.includes(".")) {
const [prefix, actualColumn] = compColumnName.split(".");
if (prefix === entityTable && actualColumn) {
targets.push({ sourceField: actualColumn, targetColumnName: compColumnName });
}
}
}
return targets;
}, [source, entityTable, allComponents, id]);
// 엔티티 autoFill 적용 래퍼
const handleChangeWithAutoFill = useCallback((newValue: string | string[]) => {
onChange?.(newValue);
if (autoFillTargets.length === 0 || !onFormDataChange || !entityTable) return;
const selectedKey = typeof newValue === "string" ? newValue : newValue[0];
if (!selectedKey) return;
const valueCol = entityValueColumn || "id";
apiClient.get(`/table-management/tables/${entityTable}/data-with-joins`, {
params: {
page: 1,
size: 1,
search: JSON.stringify({ [valueCol]: selectedKey }),
autoFilter: JSON.stringify({ enabled: true, filterColumn: "company_code", userField: "companyCode" }),
},
}).then((res) => {
const responseData = res.data?.data;
const rows = responseData?.data || responseData?.rows || [];
if (rows.length > 0) {
const fullData = rows[0];
for (const target of autoFillTargets) {
const sourceValue = fullData[target.sourceField];
if (sourceValue !== undefined) {
onFormDataChange(target.targetColumnName, sourceValue);
}
}
}
}).catch((err) => console.error("autoFill 조회 실패:", err));
}, [onChange, autoFillTargets, onFormDataChange, entityTable, entityValueColumn]);
// 모드별 컴포넌트 렌더링 // 모드별 컴포넌트 렌더링
const renderSelect = () => { const renderSelect = () => {
if (loading) { if (loading) {
@ -876,12 +941,12 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
switch (config.mode) { switch (config.mode) {
case "dropdown": case "dropdown":
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운 case "combobox":
return ( return (
<DropdownSelect <DropdownSelect
options={options} options={options}
value={value} value={value}
onChange={onChange} onChange={handleChangeWithAutoFill}
placeholder="선택" placeholder="선택"
searchable={config.mode === "combobox" ? true : config.searchable} searchable={config.mode === "combobox" ? true : config.searchable}
multiple={config.multiple} multiple={config.multiple}
@ -897,18 +962,18 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<RadioSelect <RadioSelect
options={options} options={options}
value={typeof value === "string" ? value : value?.[0]} value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)} onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled} disabled={isDisabled}
/> />
); );
case "check": case "check":
case "checkbox": // 🔧 기존 저장된 값 호환 case "checkbox":
return ( return (
<CheckSelect <CheckSelect
options={options} options={options}
value={Array.isArray(value) ? value : value ? [value] : []} value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange} onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect} maxSelect={config.maxSelect}
disabled={isDisabled} disabled={isDisabled}
/> />
@ -919,7 +984,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<TagSelect <TagSelect
options={options} options={options}
value={Array.isArray(value) ? value : value ? [value] : []} value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange} onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect} maxSelect={config.maxSelect}
disabled={isDisabled} disabled={isDisabled}
/> />
@ -930,7 +995,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<TagboxSelect <TagboxSelect
options={options} options={options}
value={Array.isArray(value) ? value : value ? [value] : []} value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange} onChange={handleChangeWithAutoFill}
placeholder={config.placeholder || "선택하세요"} placeholder={config.placeholder || "선택하세요"}
maxSelect={config.maxSelect} maxSelect={config.maxSelect}
disabled={isDisabled} disabled={isDisabled}
@ -943,7 +1008,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<ToggleSelect <ToggleSelect
options={options} options={options}
value={typeof value === "string" ? value : value?.[0]} value={typeof value === "string" ? value : value?.[0]}
onChange={(v) => onChange?.(v)} onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled} disabled={isDisabled}
/> />
); );
@ -953,7 +1018,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<SwapSelect <SwapSelect
options={options} options={options}
value={Array.isArray(value) ? value : value ? [value] : []} value={Array.isArray(value) ? value : value ? [value] : []}
onChange={onChange} onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect} maxSelect={config.maxSelect}
disabled={isDisabled} disabled={isDisabled}
/> />
@ -964,7 +1029,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
<DropdownSelect <DropdownSelect
options={options} options={options}
value={value} value={value}
onChange={onChange} onChange={handleChangeWithAutoFill}
disabled={isDisabled} disabled={isDisabled}
style={heightStyle} style={heightStyle}
/> />

File diff suppressed because it is too large Load Diff

View File

@ -1214,13 +1214,21 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" /> <Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
)} )}
{/* 편집 가능 체크박스 */} {/* 편집 가능 토글 */}
{!col.isSourceDisplay && ( {!col.isSourceDisplay && (
<Checkbox <button
checked={col.editable ?? true} type="button"
onCheckedChange={(checked) => updateColumnProp(col.key, "editable", !!checked)} onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
title="편집 가능" className={cn(
/> "shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors",
(col.editable ?? true)
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
)}
title={(col.editable ?? true) ? "편집 가능 (클릭하여 읽기 전용으로 변경)" : "읽기 전용 (클릭하여 편집 가능으로 변경)"}
>
{(col.editable ?? true) ? "편집" : "읽기"}
</button>
)} )}
<Button <Button

View File

@ -20,41 +20,111 @@ interface ColumnOption {
columnLabel: string; columnLabel: string;
} }
interface CategoryValueOption {
valueCode: string;
valueLabel: string;
}
interface V2SelectConfigPanelProps { interface V2SelectConfigPanelProps {
config: Record<string, any>; config: Record<string, any>;
onChange: (config: Record<string, any>) => void; onChange: (config: Record<string, any>) => void;
/** 컬럼의 inputType (entity 타입인 경우에만 엔티티 소스 표시) */ /** 컬럼의 inputType (entity/category 타입 확인용) */
inputType?: string; inputType?: string;
/** 현재 테이블명 (카테고리 값 조회용) */
tableName?: string;
/** 현재 컬럼명 (카테고리 값 조회용) */
columnName?: string;
} }
export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config, onChange, inputType }) => { export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({
// 엔티티 타입인지 확인 config,
onChange,
inputType,
tableName,
columnName,
}) => {
const isEntityType = inputType === "entity"; const isEntityType = inputType === "entity";
// 엔티티 테이블의 컬럼 목록 const isCategoryType = inputType === "category";
const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]); const [entityColumns, setEntityColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false);
// 설정 업데이트 핸들러 // 카테고리 값 목록
const [categoryValues, setCategoryValues] = useState<CategoryValueOption[]>([]);
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
const updateConfig = (field: string, value: any) => { const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value }); onChange({ ...config, [field]: value });
}; };
// 카테고리 타입이면 source를 자동으로 category로 설정
useEffect(() => {
if (isCategoryType && config.source !== "category") {
onChange({ ...config, source: "category" });
}
}, [isCategoryType]);
// 카테고리 값 로드
const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => {
if (!catTable || !catColumn) {
setCategoryValues([]);
return;
}
setLoadingCategoryValues(true);
try {
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
const data = response.data;
if (data.success && data.data) {
const flattenTree = (items: any[], depth: number = 0): CategoryValueOption[] => {
const result: CategoryValueOption[] = [];
for (const item of items) {
result.push({
valueCode: item.valueCode,
valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel,
});
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
}
}
return result;
};
setCategoryValues(flattenTree(data.data));
}
} catch (error) {
console.error("카테고리 값 조회 실패:", error);
setCategoryValues([]);
} finally {
setLoadingCategoryValues(false);
}
}, []);
// 카테고리 소스일 때 값 로드
useEffect(() => {
if (config.source === "category") {
const catTable = config.categoryTable || tableName;
const catColumn = config.categoryColumn || columnName;
if (catTable && catColumn) {
loadCategoryValues(catTable, catColumn);
}
}
}, [config.source, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]);
// 엔티티 테이블 변경 시 컬럼 목록 조회 // 엔티티 테이블 변경 시 컬럼 목록 조회
const loadEntityColumns = useCallback(async (tableName: string) => { const loadEntityColumns = useCallback(async (tblName: string) => {
if (!tableName) { if (!tblName) {
setEntityColumns([]); setEntityColumns([]);
return; return;
} }
setLoadingColumns(true); setLoadingColumns(true);
try { try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`); const response = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`);
const data = response.data.data || response.data; const data = response.data.data || response.data;
const columns = data.columns || data || []; const columns = data.columns || data || [];
const columnOptions: ColumnOption[] = columns.map((col: any) => { const columnOptions: ColumnOption[] = columns.map((col: any) => {
const name = col.columnName || col.column_name || col.name; const name = col.columnName || col.column_name || col.name;
// displayName 우선 사용
const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name; const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name;
return { return {
@ -72,7 +142,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
} }
}, []); }, []);
// 엔티티 테이블이 변경되면 컬럼 목록 로드
useEffect(() => { useEffect(() => {
if (config.source === "entity" && config.entityTable) { if (config.source === "entity" && config.entityTable) {
loadEntityColumns(config.entityTable); loadEntityColumns(config.entityTable);
@ -98,6 +167,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
updateConfig("options", newOptions); updateConfig("options", newOptions);
}; };
// 현재 source 결정 (카테고리 타입이면 강제 category)
const effectiveSource = isCategoryType ? "category" : config.source || "static";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 선택 모드 */} {/* 선택 모드 */}
@ -125,21 +197,102 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
{/* 데이터 소스 */} {/* 데이터 소스 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}> {isCategoryType ? (
<SelectTrigger className="h-8 text-xs"> <div className="bg-muted flex h-8 items-center rounded-md px-3">
<SelectValue placeholder="소스 선택" /> <span className="text-xs font-medium text-emerald-600"> ( )</span>
</SelectTrigger> </div>
<SelectContent> ) : (
<SelectItem value="static"> </SelectItem> <Select value={config.source || "static"} onValueChange={(value) => updateConfig("source", value)}>
<SelectItem value="code"> </SelectItem> <SelectTrigger className="h-8 text-xs">
{/* 엔티티 타입일 때만 엔티티 옵션 표시 */} <SelectValue placeholder="소스 선택" />
{isEntityType && <SelectItem value="entity"></SelectItem>} </SelectTrigger>
</SelectContent> <SelectContent>
</Select> <SelectItem value="static"> </SelectItem>
<SelectItem value="code"> </SelectItem>
<SelectItem value="category"></SelectItem>
{isEntityType && <SelectItem value="entity"></SelectItem>}
</SelectContent>
</Select>
)}
</div> </div>
{/* 카테고리 설정 */}
{effectiveSource === "category" && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs font-medium"> </Label>
<div className="bg-muted rounded-md p-2">
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-muted-foreground text-[10px]"></p>
<p className="text-xs font-medium">{config.categoryTable || tableName || "-"}</p>
</div>
<div>
<p className="text-muted-foreground text-[10px]"></p>
<p className="text-xs font-medium">{config.categoryColumn || columnName || "-"}</p>
</div>
</div>
</div>
</div>
{/* 카테고리 값 로딩 중 */}
{loadingCategoryValues && (
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
)}
{/* 카테고리 값 목록 표시 */}
{categoryValues.length > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium"> ({categoryValues.length})</Label>
<div className="bg-muted max-h-32 space-y-0.5 overflow-y-auto rounded-md p-1.5">
{categoryValues.map((cv) => (
<div key={cv.valueCode} className="flex items-center gap-2 px-1.5 py-0.5">
<span className="text-muted-foreground shrink-0 font-mono text-[10px]">{cv.valueCode}</span>
<span className="truncate text-xs">{cv.valueLabel}</span>
</div>
))}
</div>
</div>
)}
{/* 기본값 설정 */}
{categoryValues.length > 0 && (
<div className="border-t pt-2">
<Label className="text-xs font-medium"></Label>
<Select
value={config.defaultValue || "_none_"}
onValueChange={(value) => updateConfig("defaultValue", value === "_none_" ? "" : value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{categoryValues.map((cv) => (
<SelectItem key={cv.valueCode} value={cv.valueCode}>
{cv.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]"> </p>
</div>
)}
{/* 카테고리 값 없음 안내 */}
{!loadingCategoryValues && categoryValues.length === 0 && (
<p className="text-[10px] text-amber-600">
. .
</p>
)}
</div>
)}
{/* 정적 옵션 관리 */} {/* 정적 옵션 관리 */}
{(config.source || "static") === "static" && ( {effectiveSource === "static" && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
@ -199,8 +352,8 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
</div> </div>
)} )}
{/* 공통 코드 설정 - 테이블 타입 관리에서 설정되므로 정보만 표시 */} {/* 공통 코드 설정 */}
{config.source === "code" && ( {effectiveSource === "code" && (
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
{config.codeGroup ? ( {config.codeGroup ? (
@ -212,7 +365,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
)} )}
{/* 엔티티(참조 테이블) 설정 */} {/* 엔티티(참조 테이블) 설정 */}
{config.source === "entity" && ( {effectiveSource === "entity" && (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
@ -228,7 +381,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
</p> </p>
</div> </div>
{/* 컬럼 로딩 중 표시 */}
{loadingColumns && ( {loadingColumns && (
<div className="text-muted-foreground flex items-center gap-2 text-xs"> <div className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
@ -236,7 +388,6 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
</div> </div>
)} )}
{/* 컬럼 선택 - 테이블이 설정되어 있고 컬럼 목록이 있는 경우 Select로 표시 */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> ()</Label> <Label className="text-xs font-medium"> ()</Label>
@ -296,12 +447,20 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
</div> </div>
</div> </div>
{/* 컬럼이 없는 경우 안내 */}
{config.entityTable && !loadingColumns && entityColumns.length === 0 && ( {config.entityTable && !loadingColumns && entityColumns.length === 0 && (
<p className="text-[10px] text-amber-600"> <p className="text-[10px] text-amber-600">
. . . .
</p> </p>
)} )}
{config.entityTable && entityColumns.length > 0 && (
<div className="border-t pt-3">
<p className="text-muted-foreground text-[10px]">
({config.entityTable}) ,
.
</p>
</div>
)}
</div> </div>
)} )}

View File

@ -6,17 +6,28 @@
"use client"; "use client";
import React, { createContext, useContext, useCallback, useRef, useState } from "react"; import React, { createContext, useContext, useCallback, useRef, useState } from "react";
import type { DataProvidable, DataReceivable } from "@/types/data-transfer"; import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
import { logger } from "@/lib/utils/logger"; import { logger } from "@/lib/utils/logger";
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext"; import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
/**
*
* ( )
*/
export interface PendingTransfer {
targetComponentId: string;
data: any[];
config: DataReceiverConfig;
timestamp: number;
targetLayerId?: string;
}
interface ScreenContextValue { interface ScreenContextValue {
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요) menuObjid?: number;
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right) splitPanelPosition?: SplitPanelPosition;
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
formData: Record<string, any>; formData: Record<string, any>;
updateFormData: (fieldName: string, value: any) => void; updateFormData: (fieldName: string, value: any) => void;
@ -33,6 +44,11 @@ interface ScreenContextValue {
// 모든 컴포넌트 조회 // 모든 컴포넌트 조회
getAllDataProviders: () => Map<string, DataProvidable>; getAllDataProviders: () => Map<string, DataProvidable>;
getAllDataReceivers: () => Map<string, DataReceivable>; getAllDataReceivers: () => Map<string, DataReceivable>;
// 대기 중인 데이터 전달 (레이어 내부 컴포넌트 미마운트 대응)
addPendingTransfer: (transfer: PendingTransfer) => void;
getPendingTransfer: (componentId: string) => PendingTransfer | undefined;
clearPendingTransfer: (componentId: string) => void;
} }
const ScreenContext = createContext<ScreenContextValue | null>(null); const ScreenContext = createContext<ScreenContextValue | null>(null);
@ -57,11 +73,10 @@ export function ScreenContextProvider({
}: ScreenContextProviderProps) { }: ScreenContextProviderProps) {
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map()); const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map()); const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
const pendingTransfersRef = useRef<Map<string, PendingTransfer>>(new Map());
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
// 🆕 폼 데이터 업데이트 함수
const updateFormData = useCallback((fieldName: string, value: any) => { const updateFormData = useCallback((fieldName: string, value: any) => {
setFormData((prev) => { setFormData((prev) => {
const updated = { ...prev, [fieldName]: value }; const updated = { ...prev, [fieldName]: value };
@ -87,6 +102,25 @@ export function ScreenContextProvider({
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => { const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
dataReceiversRef.current.set(componentId, receiver); dataReceiversRef.current.set(componentId, receiver);
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType }); logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
// 대기 중인 데이터 전달이 있으면 즉시 수신 처리
const pending = pendingTransfersRef.current.get(componentId);
if (pending) {
logger.info("대기 중인 데이터 전달 자동 수신", {
componentId,
dataCount: pending.data.length,
waitedMs: Date.now() - pending.timestamp,
});
receiver
.receiveData(pending.data, pending.config)
.then(() => {
pendingTransfersRef.current.delete(componentId);
logger.info("대기 데이터 전달 완료", { componentId });
})
.catch((err) => {
logger.error("대기 데이터 전달 실패", { componentId, error: err });
});
}
}, []); }, []);
const unregisterDataReceiver = useCallback((componentId: string) => { const unregisterDataReceiver = useCallback((componentId: string) => {
@ -110,7 +144,24 @@ export function ScreenContextProvider({
return new Map(dataReceiversRef.current); return new Map(dataReceiversRef.current);
}, []); }, []);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) const addPendingTransfer = useCallback((transfer: PendingTransfer) => {
pendingTransfersRef.current.set(transfer.targetComponentId, transfer);
logger.info("데이터 전달 대기열 추가", {
targetComponentId: transfer.targetComponentId,
dataCount: transfer.data.length,
targetLayerId: transfer.targetLayerId,
});
}, []);
const getPendingTransfer = useCallback((componentId: string) => {
return pendingTransfersRef.current.get(componentId);
}, []);
const clearPendingTransfer = useCallback((componentId: string) => {
pendingTransfersRef.current.delete(componentId);
logger.debug("대기 데이터 전달 클리어", { componentId });
}, []);
const value = React.useMemo<ScreenContextValue>( const value = React.useMemo<ScreenContextValue>(
() => ({ () => ({
screenId, screenId,
@ -127,6 +178,9 @@ export function ScreenContextProvider({
getDataReceiver, getDataReceiver,
getAllDataProviders, getAllDataProviders,
getAllDataReceivers, getAllDataReceivers,
addPendingTransfer,
getPendingTransfer,
clearPendingTransfer,
}), }),
[ [
screenId, screenId,
@ -143,6 +197,9 @@ export function ScreenContextProvider({
getDataReceiver, getDataReceiver,
getAllDataProviders, getAllDataProviders,
getAllDataReceivers, getAllDataReceivers,
addPendingTransfer,
getPendingTransfer,
clearPendingTransfer,
], ],
); );

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiCall } from "@/lib/api/client"; import { apiCall } from "@/lib/api/client";
import { AuthLogger } from "@/lib/authLogger";
interface UserInfo { interface UserInfo {
userId: string; userId: string;
@ -161,13 +162,15 @@ export const useAuth = () => {
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (!token || TokenManager.isTokenExpired(token)) { if (!token || TokenManager.isTokenExpired(token)) {
AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`);
setUser(null); setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false); setLoading(false);
return; return;
} }
// 토큰이 유효하면 우선 인증된 상태로 설정 AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작");
setAuthStatus({ setAuthStatus({
isLoggedIn: true, isLoggedIn: true,
isAdmin: false, isAdmin: false,
@ -186,15 +189,16 @@ export const useAuth = () => {
}; };
setAuthStatus(finalAuthStatus); setAuthStatus(finalAuthStatus);
AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`);
// API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리)
if (!finalAuthStatus.isLoggedIn) { if (!finalAuthStatus.isLoggedIn) {
AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거");
TokenManager.removeToken(); TokenManager.removeToken();
setUser(null); setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
} }
} else { } else {
// userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지 AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도");
try { try {
const payload = JSON.parse(atob(token.split(".")[1])); const payload = JSON.parse(atob(token.split(".")[1]));
const tempUser: UserInfo = { const tempUser: UserInfo = {
@ -210,14 +214,14 @@ export const useAuth = () => {
isAdmin: tempUser.isAdmin, isAdmin: tempUser.isAdmin,
}); });
} catch { } catch {
// 토큰 파싱도 실패하면 비인증 상태로 전환 AuthLogger.log("AUTH_CHECK_FAIL", "토큰 파싱 실패 → 비인증 전환");
TokenManager.removeToken(); TokenManager.removeToken();
setUser(null); setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
} }
} }
} catch { } catch {
// API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도 AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도");
try { try {
const payload = JSON.parse(atob(token.split(".")[1])); const payload = JSON.parse(atob(token.split(".")[1]));
const tempUser: UserInfo = { const tempUser: UserInfo = {
@ -233,6 +237,7 @@ export const useAuth = () => {
isAdmin: tempUser.isAdmin, isAdmin: tempUser.isAdmin,
}); });
} catch { } catch {
AuthLogger.log("AUTH_CHECK_FAIL", "최종 fallback 실패 → 비인증 전환");
TokenManager.removeToken(); TokenManager.removeToken();
setUser(null); setUser(null);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
@ -408,19 +413,19 @@ export const useAuth = () => {
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (token && !TokenManager.isTokenExpired(token)) { if (token && !TokenManager.isTokenExpired(token)) {
// 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인 AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`);
setAuthStatus({ setAuthStatus({
isLoggedIn: true, isLoggedIn: true,
isAdmin: false, isAdmin: false,
}); });
refreshUserData(); refreshUserData();
} else if (token && TokenManager.isTokenExpired(token)) { } else if (token && TokenManager.isTokenExpired(token)) {
// 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리) AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`);
TokenManager.removeToken(); TokenManager.removeToken();
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false); setLoading(false);
} else { } else {
// 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리) AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`);
setAuthStatus({ isLoggedIn: false, isAdmin: false }); setAuthStatus({ isLoggedIn: false, isAdmin: false });
setLoading(false); setLoading(false);
} }

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MenuItem, MenuState } from "@/types/menu"; import { MenuItem, MenuState } from "@/types/menu";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { AuthLogger } from "@/lib/authLogger";
/** /**
* *
@ -84,8 +85,8 @@ export const useMenu = (user: any, authLoading: boolean) => {
} else { } else {
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
} }
} catch { } catch (err: any) {
// API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리) AuthLogger.log("MENU_LOAD_FAIL", `메뉴 로드 실패: ${err?.response?.status || err?.message || "unknown"}`);
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
} }
}, [convertToUpperCaseKeys, buildMenuTree]); }, [convertToUpperCaseKeys, buildMenuTree]);

View File

@ -1,4 +1,14 @@
import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios"; import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios";
import { AuthLogger } from "@/lib/authLogger";
const authLog = (event: string, detail: string) => {
if (typeof window === "undefined") return;
try {
AuthLogger.log(event as any, detail);
} catch {
// 로거 실패해도 앱 동작에 영향 없음
}
};
// API URL 동적 설정 - 환경변수 우선 사용 // API URL 동적 설정 - 환경변수 우선 사용
const getApiBaseUrl = (): string => { const getApiBaseUrl = (): string => {
@ -149,9 +159,12 @@ const refreshToken = async (): Promise<string | null> => {
try { try {
const currentToken = TokenManager.getToken(); const currentToken = TokenManager.getToken();
if (!currentToken) { if (!currentToken) {
authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음");
return null; return null;
} }
authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}`);
const response = await axios.post( const response = await axios.post(
`${API_BASE_URL}/auth/refresh`, `${API_BASE_URL}/auth/refresh`,
{}, {},
@ -165,10 +178,13 @@ const refreshToken = async (): Promise<string | null> => {
if (response.data?.success && response.data?.data?.token) { if (response.data?.success && response.data?.data?.token) {
const newToken = response.data.data.token; const newToken = response.data.data.token;
TokenManager.setToken(newToken); TokenManager.setToken(newToken);
authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료");
return newToken; return newToken;
} }
authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`);
return null; return null;
} catch { } catch (err: any) {
authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`);
return null; return null;
} }
}; };
@ -210,16 +226,21 @@ const setupVisibilityRefresh = (): void => {
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (!document.hidden) { if (!document.hidden) {
const token = TokenManager.getToken(); const token = TokenManager.getToken();
if (!token) return; if (!token) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음");
return;
}
if (TokenManager.isTokenExpired(token)) { if (TokenManager.isTokenExpired(token)) {
// 만료됐으면 갱신 시도 authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도");
refreshToken().then((newToken) => { refreshToken().then((newToken) => {
if (!newToken) { if (!newToken) {
authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트");
redirectToLogin(); redirectToLogin();
} }
}); });
} else if (TokenManager.isTokenExpiringSoon(token)) { } else if (TokenManager.isTokenExpiringSoon(token)) {
authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도");
refreshToken(); refreshToken();
} }
} }
@ -268,6 +289,7 @@ const redirectToLogin = (): void => {
if (isRedirecting) return; if (isRedirecting) return;
if (window.location.pathname === "/login") return; if (window.location.pathname === "/login") return;
authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`);
isRedirecting = true; isRedirecting = true;
TokenManager.removeToken(); TokenManager.removeToken();
window.location.href = "/login"; window.location.href = "/login";
@ -301,15 +323,13 @@ apiClient.interceptors.request.use(
if (token) { if (token) {
if (!TokenManager.isTokenExpired(token)) { if (!TokenManager.isTokenExpired(token)) {
// 유효한 토큰 → 그대로 사용
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} else { } else {
// 만료된 토큰 → 갱신 시도 후 사용 authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`);
const newToken = await refreshToken(); const newToken = await refreshToken();
if (newToken) { if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`; config.headers.Authorization = `Bearer ${newToken}`;
} }
// 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리)
} }
} }
@ -378,12 +398,16 @@ apiClient.interceptors.response.use(
// 401 에러 처리 (핵심 개선) // 401 에러 처리 (핵심 개선)
if (status === 401 && typeof window !== "undefined") { if (status === 401 && typeof window !== "undefined") {
const errorData = error.response?.data as { error?: { code?: string } }; const errorData = error.response?.data as { error?: { code?: string; details?: string } };
const errorCode = errorData?.error?.code; const errorCode = errorData?.error?.code;
const errorDetails = errorData?.error?.details;
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`);
// 이미 재시도한 요청이면 로그인으로 // 이미 재시도한 요청이면 로그인으로
if (originalRequest?._retry) { if (originalRequest?._retry) {
authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`);
redirectToLogin(); redirectToLogin();
return Promise.reject(error); return Promise.reject(error);
} }
@ -395,6 +419,7 @@ apiClient.interceptors.response.use(
originalRequest._retry = true; originalRequest._retry = true;
try { try {
authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`);
const newToken = await refreshToken(); const newToken = await refreshToken();
if (newToken) { if (newToken) {
isRefreshing = false; isRefreshing = false;
@ -404,17 +429,18 @@ apiClient.interceptors.response.use(
} else { } else {
isRefreshing = false; isRefreshing = false;
onRefreshFailed(new Error("토큰 갱신 실패")); onRefreshFailed(new Error("토큰 갱신 실패"));
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`);
redirectToLogin(); redirectToLogin();
return Promise.reject(error); return Promise.reject(error);
} }
} catch (refreshError) { } catch (refreshError) {
isRefreshing = false; isRefreshing = false;
onRefreshFailed(refreshError as Error); onRefreshFailed(refreshError as Error);
authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`);
redirectToLogin(); redirectToLogin();
return Promise.reject(error); return Promise.reject(error);
} }
} else { } else {
// 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도
try { try {
const newToken = await waitForTokenRefresh(); const newToken = await waitForTokenRefresh();
originalRequest._retry = true; originalRequest._retry = true;
@ -427,6 +453,7 @@ apiClient.interceptors.response.use(
} }
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로 // TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`);
redirectToLogin(); redirectToLogin();
} }

225
frontend/lib/authLogger.ts Normal file
View File

@ -0,0 +1,225 @@
/**
*
* - //
* - localStorage에
* - window.__AUTH_LOG.show()
*/
const STORAGE_KEY = "auth_debug_log";
const MAX_ENTRIES = 200;
export type AuthEventType =
| "TOKEN_SET"
| "TOKEN_REMOVED"
| "TOKEN_EXPIRED_DETECTED"
| "TOKEN_REFRESH_START"
| "TOKEN_REFRESH_SUCCESS"
| "TOKEN_REFRESH_FAIL"
| "REDIRECT_TO_LOGIN"
| "API_401_RECEIVED"
| "API_401_RETRY"
| "AUTH_CHECK_START"
| "AUTH_CHECK_SUCCESS"
| "AUTH_CHECK_FAIL"
| "AUTH_GUARD_BLOCK"
| "AUTH_GUARD_PASS"
| "MENU_LOAD_FAIL"
| "VISIBILITY_CHANGE"
| "MIDDLEWARE_REDIRECT";
interface AuthLogEntry {
timestamp: string;
event: AuthEventType;
detail: string;
tokenStatus: string;
url: string;
stack?: string;
}
function getTokenSummary(): string {
if (typeof window === "undefined") return "SSR";
const token = localStorage.getItem("authToken");
if (!token) return "없음";
try {
const payload = JSON.parse(atob(token.split(".")[1]));
const exp = payload.exp * 1000;
const now = Date.now();
const remainMs = exp - now;
if (remainMs <= 0) {
return `만료됨(${Math.abs(Math.round(remainMs / 60000))}분 전)`;
}
const remainMin = Math.round(remainMs / 60000);
const remainHour = Math.floor(remainMin / 60);
const min = remainMin % 60;
return `유효(${remainHour}h${min}m 남음, user:${payload.userId})`;
} catch {
return "파싱실패";
}
}
function getCallStack(): string {
try {
const stack = new Error().stack || "";
const lines = stack.split("\n").slice(3, 7);
return lines.map((l) => l.trim()).join(" <- ");
} catch {
return "";
}
}
function writeLog(event: AuthEventType, detail: string) {
if (typeof window === "undefined") return;
const entry: AuthLogEntry = {
timestamp: new Date().toISOString(),
event,
detail,
tokenStatus: getTokenSummary(),
url: window.location.pathname + window.location.search,
stack: getCallStack(),
};
// 콘솔 출력 (그룹)
const isError = ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK"].includes(event);
const logFn = isError ? console.warn : console.debug;
logFn(`[AuthLog] ${event}: ${detail} | 토큰: ${entry.tokenStatus} | ${entry.url}`);
// localStorage에 저장
try {
const stored = localStorage.getItem(STORAGE_KEY);
const logs: AuthLogEntry[] = stored ? JSON.parse(stored) : [];
logs.push(entry);
// 최대 개수 초과 시 오래된 것 제거
while (logs.length > MAX_ENTRIES) {
logs.shift();
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(logs));
} catch {
// localStorage 공간 부족 등의 경우 무시
}
}
/**
*
*/
function getLogs(): AuthLogEntry[] {
if (typeof window === "undefined") return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
*
*/
function clearLogs() {
if (typeof window === "undefined") return;
localStorage.removeItem(STORAGE_KEY);
}
/**
*
*/
function showLogs(filter?: AuthEventType | "ERROR") {
const logs = getLogs();
if (logs.length === 0) {
console.log("[AuthLog] 저장된 로그가 없습니다.");
return;
}
let filtered = logs;
if (filter === "ERROR") {
filtered = logs.filter((l) =>
["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK", "AUTH_CHECK_FAIL", "TOKEN_EXPIRED_DETECTED"].includes(l.event)
);
} else if (filter) {
filtered = logs.filter((l) => l.event === filter);
}
console.log(`\n[AuthLog] 총 ${filtered.length}건 (전체 ${logs.length}건)`);
console.log("─".repeat(120));
filtered.forEach((entry, i) => {
const time = entry.timestamp.replace("T", " ").split(".")[0];
console.log(
`${i + 1}. [${time}] ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}\n`
);
});
}
/**
*
*/
function getLastRedirectReason(): AuthLogEntry | null {
const logs = getLogs();
for (let i = logs.length - 1; i >= 0; i--) {
if (logs[i].event === "REDIRECT_TO_LOGIN") {
return logs[i];
}
}
return null;
}
/**
*
*/
function downloadLogs() {
if (typeof window === "undefined") return;
const logs = getLogs();
if (logs.length === 0) {
console.log("[AuthLog] 저장된 로그가 없습니다.");
return;
}
const text = logs
.map((entry, i) => {
const time = entry.timestamp.replace("T", " ").split(".")[0];
return `[${i + 1}] ${time} | ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}`;
})
.join("\n\n");
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `auth-debug-log_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.txt`;
a.click();
URL.revokeObjectURL(url);
console.log("[AuthLog] 로그 파일 다운로드 완료");
}
// 전역 접근 가능하게 등록
if (typeof window !== "undefined") {
(window as any).__AUTH_LOG = {
show: showLogs,
errors: () => showLogs("ERROR"),
clear: clearLogs,
download: downloadLogs,
lastRedirect: getLastRedirectReason,
raw: getLogs,
};
}
export const AuthLogger = {
log: writeLog,
getLogs,
clearLogs,
showLogs,
downloadLogs,
getLastRedirectReason,
};
export default AuthLogger;

View File

@ -8,6 +8,76 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter";
// 통합 폼 시스템 import // 통합 폼 시스템 import
import { useV2FormOptional } from "@/components/v2/V2FormContext"; import { useV2FormOptional } from "@/components/v2/V2FormContext";
import { apiClient } from "@/lib/api/client";
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
const columnMetaCache: Record<string, Record<string, any>> = {};
const columnMetaLoading: Record<string, Promise<void>> = {};
async function loadColumnMeta(tableName: string): Promise<void> {
if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return;
columnMetaLoading[tableName] = (async () => {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
const data = response.data.data || response.data;
const columns = data.columns || data || [];
const map: Record<string, any> = {};
for (const col of columns) {
const name = col.column_name || col.columnName;
if (name) map[name] = col;
}
columnMetaCache[tableName] = map;
} catch {
columnMetaCache[tableName] = {};
} finally {
delete columnMetaLoading[tableName];
}
})();
await columnMetaLoading[tableName];
}
// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
if (!tableName || !columnName) return componentConfig;
const meta = columnMetaCache[tableName]?.[columnName];
if (!meta) return componentConfig;
const inputType = meta.input_type || meta.inputType;
if (!inputType) return componentConfig;
// 이미 source가 올바르게 설정된 경우 건드리지 않음
const existingSource = componentConfig?.source;
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
return componentConfig;
}
const merged = { ...componentConfig };
// source가 미설정/기본값일 때만 DB 메타데이터로 보완
if (inputType === "entity") {
const refTable = meta.reference_table || meta.referenceTable;
const refColumn = meta.reference_column || meta.referenceColumn;
const displayCol = meta.display_column || meta.displayColumn;
if (refTable && !merged.entityTable) {
merged.source = "entity";
merged.entityTable = refTable;
merged.entityValueColumn = refColumn || "id";
merged.entityLabelColumn = displayCol || "name";
}
} else if (inputType === "category" && !existingSource) {
merged.source = "category";
} else if (inputType === "select" && !existingSource) {
const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : (meta.detail_settings || {});
if (detail.options && !merged.options?.length) {
merged.options = detail.options;
}
}
return merged;
}
// 컴포넌트 렌더러 인터페이스 // 컴포넌트 렌더러 인터페이스
export interface ComponentRenderer { export interface ComponentRenderer {
@ -175,6 +245,15 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
children, children,
...props ...props
}) => { }) => {
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
const screenTableName = props.tableName || (component as any).tableName;
const [, forceUpdate] = React.useState(0);
React.useEffect(() => {
if (screenTableName && !columnMetaCache[screenTableName]) {
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
}
}, [screenTableName]);
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
// 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input")
const extractTypeFromUrl = (url: string | undefined): string | undefined => { const extractTypeFromUrl = (url: string | undefined): string | undefined => {
@ -551,24 +630,34 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
height: finalStyle.height, height: finalStyle.height,
}; };
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
const isEntityJoinColumn = fieldName?.includes(".");
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
// 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제
const effectiveComponent = isEntityJoinColumn
? { ...component, componentConfig: mergedComponentConfig, readonly: false }
: { ...component, componentConfig: mergedComponentConfig };
const rendererProps = { const rendererProps = {
component, component: effectiveComponent,
isSelected, isSelected,
onClick, onClick,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
size: component.size || newComponent.defaultSize, size: component.size || newComponent.defaultSize,
position: component.position, position: component.position,
config: component.componentConfig, config: mergedComponentConfig,
componentConfig: component.componentConfig, componentConfig: mergedComponentConfig,
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
...(component.componentConfig || {}), ...(mergedComponentConfig || {}),
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
style: mergedStyle, style: mergedStyle,
// 🆕 라벨 표시 (labelDisplay가 true일 때만) // 🆕 라벨 표시 (labelDisplay가 true일 때만)
label: effectiveLabel, label: effectiveLabel,
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
inputType: (component as any).inputType || component.componentConfig?.inputType, inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType,
columnName: (component as any).columnName || component.componentConfig?.columnName, columnName: (component as any).columnName || component.componentConfig?.columnName,
value: currentValue, // formData에서 추출한 현재 값 전달 value: currentValue, // formData에서 추출한 현재 값 전달
// 새로운 기능들 전달 // 새로운 기능들 전달
@ -608,9 +697,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드) // componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드)
mode: component.componentConfig?.mode || mode, mode: component.componentConfig?.mode || mode,
isInModal, isInModal,
readonly: component.readonly, readonly: isEntityJoinColumn ? false : component.readonly,
// 🆕 disabledFields 체크 또는 기존 readonly disabled: isEntityJoinColumn ? false : (disabledFields?.includes(fieldName) || component.readonly),
disabled: disabledFields?.includes(fieldName) || component.readonly,
originalData, originalData,
allComponents, allComponents,
onUpdateLayout, onUpdateLayout,

View File

@ -480,15 +480,20 @@ export function RepeaterTable({
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
const value = row[column.field]; const value = row[column.field];
// 🆕 카테고리 라벨 변환 함수 // 카테고리 라벨 변환 함수
const getCategoryDisplayValue = (val: any): string => { const getCategoryDisplayValue = (val: any): string => {
if (!val || typeof val !== "string") return val || "-"; if (!val || typeof val !== "string") return val || "-";
// 카테고리 컬럼이 아니면 그대로 반환 const fieldName = column.field.replace(/^_display_/, "");
const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거 const isCategoryColumn = categoryColumns.includes(fieldName);
if (!categoryColumns.includes(fieldName)) return val;
// 쉼표로 구분된 다중 값 처리 // categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관)
if (categoryLabelMap[val]) return categoryLabelMap[val];
// 카테고리 컬럼이 아니면 원래 값 반환
if (!isCategoryColumn) return val;
// 콤마 구분된 다중 값 처리
const codes = val const codes = val
.split(",") .split(",")
.map((c: string) => c.trim()) .map((c: string) => c.trim())

View File

@ -23,7 +23,8 @@ import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client"; import { apiClient, getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -39,6 +40,80 @@ import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-opt
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useSplitPanel } from "./SplitPanelContext"; import { useSplitPanel } from "./SplitPanelContext";
// 테이블 셀 이미지 썸네일 컴포넌트
const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
const [error, setError] = React.useState(false);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let mounted = true;
const rawValue = String(value);
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue);
if (isObjid) {
const loadImage = async () => {
try {
const response = await apiClient.get(`/files/preview/${strValue}`, { responseType: "blob" });
if (mounted) {
const blob = new Blob([response.data]);
setImgSrc(window.URL.createObjectURL(blob));
setLoading(false);
}
} catch {
if (mounted) { setError(true); setLoading(false); }
}
};
loadImage();
} else {
setImgSrc(getFullImageUrl(strValue));
setLoading(false);
}
return () => { mounted = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
if (loading) {
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
</div>
);
}
if (error || !imgSrc) {
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<img
src={imgSrc}
alt="이미지"
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
const rawValue = String(value);
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue);
window.open(isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue), "_blank");
}}
onError={() => setError(true)}
/>
</div>
);
});
SplitPanelCellImage.displayName = "SplitPanelCellImage";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props // 추가 props
} }
@ -182,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들 const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨 const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨 const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({}); // 테이블별 컬럼 inputType
const [leftCategoryMappings, setLeftCategoryMappings] = useState< const [leftCategoryMappings, setLeftCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>> Record<string, Record<string, { label: string; color?: string }>>
>({}); // 좌측 카테고리 매핑 >({}); // 좌측 카테고리 매핑
@ -619,7 +695,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return result; return result;
}, []); }, []);
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷 + 이미지)
const formatCellValue = useCallback( const formatCellValue = useCallback(
( (
columnName: string, columnName: string,
@ -636,6 +712,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
) => { ) => {
if (value === null || value === undefined) return "-"; if (value === null || value === undefined) return "-";
// 이미지 타입: 썸네일 표시
const colInputType = columnInputTypes[columnName];
if (colInputType === "image" && value) {
return <SplitPanelCellImage value={String(value)} />;
}
// 🆕 날짜 포맷 적용 // 🆕 날짜 포맷 적용
if (format?.type === "date" || format?.dateFormat) { if (format?.type === "date" || format?.dateFormat) {
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
@ -702,7 +784,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 일반 값 // 일반 값
return String(value); return String(value);
}, },
[formatDateValue, formatNumberValue], [formatDateValue, formatNumberValue, columnInputTypes],
); );
// 좌측 데이터 로드 // 좌측 데이터 로드
@ -1453,14 +1535,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
}); });
setRightColumnLabels(labels); setRightColumnLabels(labels);
console.log("✅ 우측 컬럼 라벨 로드:", labels);
// 우측 테이블 + 추가 탭 테이블의 inputType 로드
const tablesToLoad = new Set<string>([rightTableName]);
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
additionalTabs.forEach((tab: any) => {
if (tab.tableName) tablesToLoad.add(tab.tableName);
});
const inputTypes: Record<string, string> = {};
for (const tbl of tablesToLoad) {
try {
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
inputTypesResponse.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (colName) {
inputTypes[colName] = col.inputType || "text";
}
});
} catch {
// inputType 로드 실패 시 무시
}
}
setColumnInputTypes(inputTypes);
} catch (error) { } catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error); console.error("우측 테이블 컬럼 정보 로드 실패:", error);
} }
}; };
loadRightTableColumns(); loadRightTableColumns();
}, [componentConfig.rightPanel?.tableName, isDesignMode]); }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
// 좌측 테이블 카테고리 매핑 로드 // 좌측 테이블 카테고리 매핑 로드
useEffect(() => { useEffect(() => {
@ -2534,14 +2638,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
{group.items.map((item, idx) => { {group.items.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || idx; const itemId = item[sourceColumn] || item.id || item.ID;
const isSelected = const isSelected =
selectedLeftItem && selectedLeftItem &&
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return ( return (
<tr <tr
key={itemId} key={itemId != null ? `${itemId}-${idx}` : idx}
onClick={() => handleLeftItemSelect(item)} onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${ className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : "" isSelected ? "bg-primary/10" : ""
@ -2596,14 +2700,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => { {filteredData.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || idx; const itemId = item[sourceColumn] || item.id || item.ID;
const isSelected = const isSelected =
selectedLeftItem && selectedLeftItem &&
(selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return ( return (
<tr <tr
key={itemId} key={itemId != null ? `${itemId}-${idx}` : idx}
onClick={() => handleLeftItemSelect(item)} onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${ className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : "" isSelected ? "bg-primary/10" : ""
@ -2698,7 +2802,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 재귀 렌더링 함수 // 재귀 렌더링 함수
const renderTreeItem = (item: any, index: number): React.ReactNode => { const renderTreeItem = (item: any, index: number): React.ReactNode => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
const itemId = item[sourceColumn] || item.id || item.ID || index; const rawItemId = item[sourceColumn] || item.id || item.ID;
const itemId = rawItemId != null ? rawItemId : index;
const isSelected = const isSelected =
selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
const hasChildren = item.children && item.children.length > 0; const hasChildren = item.children && item.children.length > 0;
@ -2749,7 +2854,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const displaySubtitle = displayFields[1]?.value || null; const displaySubtitle = displayFields[1]?.value || null;
return ( return (
<React.Fragment key={itemId}> <React.Fragment key={`${itemId}-${index}`}>
{/* 현재 항목 */} {/* 현재 항목 */}
<div <div
className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${ className={`group hover:bg-muted relative cursor-pointer rounded-md p-3 transition-colors ${
@ -3081,7 +3186,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{currentTabData.map((item: any, idx: number) => { {currentTabData.map((item: any, idx: number) => {
const itemId = item.id || idx; const itemId = item.id ?? idx;
const isExpanded = expandedRightItems.has(itemId); const isExpanded = expandedRightItems.has(itemId);
// 표시할 컬럼 결정 // 표시할 컬럼 결정
@ -3097,7 +3202,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const detailColumns = columnsToShow.slice(summaryCount); const detailColumns = columnsToShow.slice(summaryCount);
return ( return (
<div key={itemId} className="rounded-lg border bg-white p-3"> <div key={`${itemId}-${idx}`} className="rounded-lg border bg-white p-3">
<div <div
className="flex cursor-pointer items-start justify-between" className="flex cursor-pointer items-start justify-between"
onClick={() => toggleRightItemExpansion(itemId)} onClick={() => toggleRightItemExpansion(itemId)}
@ -3287,10 +3392,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => { {filteredData.map((item, idx) => {
const itemId = item.id || item.ID || idx; const itemId = item.id || item.ID;
return ( return (
<tr key={itemId} className="hover:bg-accent transition-colors"> <tr key={itemId != null ? `${itemId}-${idx}` : idx} className="hover:bg-accent transition-colors">
{columnsToShow.map((col, colIdx) => ( {columnsToShow.map((col, colIdx) => (
<td <td
key={colIdx} key={colIdx}
@ -3404,7 +3509,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return ( return (
<div <div
key={itemId} key={`${itemId}-${index}`}
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md" className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
> >
{/* 요약 정보 */} {/* 요약 정보 */}

View File

@ -781,6 +781,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const dataProvider: DataProvidable = { const dataProvider: DataProvidable = {
componentId: component.id, componentId: component.id,
componentType: "table-list", componentType: "table-list",
tableName: tableConfig.selectedTable,
getSelectedData: () => { getSelectedData: () => {
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
@ -940,23 +941,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
} }
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 // 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
if (response.data.success && response.data.data && response.data.data.length > 0) {
return response.data.data.map((item: any) => ({
value: String(item.value),
label: String(item.label),
}));
}
} catch {
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
}
// fallback: 현재 로드된 데이터에서 고유 값 추출
const isLabelType = ["category", "entity", "code"].includes(inputType); const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName; const labelField = isLabelType ? `${columnName}_name` : columnName;
// 현재 로드된 데이터에서 고유 값 추출 const uniqueValuesMap = new Map<string, string>();
const uniqueValuesMap = new Map<string, string>(); // value -> label
data.forEach((row) => { data.forEach((row) => {
const value = row[columnName]; const value = row[columnName];
if (value !== null && value !== undefined && value !== "") { if (value !== null && value !== undefined && value !== "") {
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
const label = isLabelType && row[labelField] ? row[labelField] : String(value); const label = isLabelType && row[labelField] ? row[labelField] : String(value);
uniqueValuesMap.set(String(value), label); uniqueValuesMap.set(String(value), label);
} }
}); });
// Map을 배열로 변환하고 라벨 기준으로 정렬
const result = Array.from(uniqueValuesMap.entries()) const result = Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({ .map(([value, label]) => ({
value: value, value: value,
@ -4192,9 +4205,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType; const inputType = meta?.inputType || column.inputType;
// 🖼️ 이미지 타입: 작은 썸네일 표시 // 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만)
if (inputType === "image" && value && typeof value === "string") { if (inputType === "image" && value && typeof value === "string") {
const imageUrl = getFullImageUrl(value); const firstImage = value.includes(",") ? value.split(",")[0].trim() : value;
const imageUrl = getFullImageUrl(firstImage);
return ( return (
<img <img
src={imageUrl} src={imageUrl}
@ -4307,7 +4321,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 다중 값인 경우: 여러 배지 렌더링 // 다중 값인 경우: 여러 배지 렌더링
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-nowrap gap-1 overflow-hidden">
{values.map((val, idx) => { {values.map((val, idx) => {
const categoryData = mapping?.[val]; const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val; const displayLabel = categoryData?.label || val;
@ -4316,7 +4330,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) { if (!displayColor || displayColor === "none" || !categoryData) {
return ( return (
<span key={idx} className="text-sm"> <span key={idx} className="shrink-0 whitespace-nowrap text-sm">
{displayLabel} {displayLabel}
{idx < values.length - 1 && ", "} {idx < values.length - 1 && ", "}
</span> </span>
@ -4330,7 +4344,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
backgroundColor: displayColor, backgroundColor: displayColor,
borderColor: displayColor, borderColor: displayColor,
}} }}
className="text-white" className="shrink-0 whitespace-nowrap text-white"
> >
{displayLabel} {displayLabel}
</Badge> </Badge>

View File

@ -554,6 +554,69 @@ export function TableSectionRenderer({
loadCategoryOptions(); loadCategoryOptions();
}, [tableConfig.source.tableName, tableConfig.columns]); }, [tableConfig.source.tableName, tableConfig.columns]);
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
useEffect(() => {
if (!formData || Object.keys(formData).length === 0) return;
if (!tableConfig.columns) return;
const codesToResolve: string[] = [];
for (const col of tableConfig.columns) {
// receiveFromParent 컬럼
if ((col as any).receiveFromParent) {
const parentField = (col as any).parentFieldName || col.field;
const val = formData[parentField];
if (typeof val === "string" && val) {
codesToResolve.push(val);
}
}
// internal 매핑 컬럼
const mapping = (col as any).valueMapping;
if (mapping?.type === "internal" && mapping.internalField) {
const val = formData[mapping.internalField];
if (typeof val === "string" && val) {
codesToResolve.push(val);
}
}
}
if (codesToResolve.length === 0) return;
const loadParentLabels = async () => {
try {
const resp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: codesToResolve,
});
if (resp.data?.success && resp.data.data) {
const labelData = resp.data.data as Record<string, string>;
// categoryOptionsMap에 추가 (receiveFromParent 컬럼별로)
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
for (const col of tableConfig.columns) {
let val: string | undefined;
if ((col as any).receiveFromParent) {
const parentField = (col as any).parentFieldName || col.field;
val = formData[parentField] as string;
}
const mapping = (col as any).valueMapping;
if (mapping?.type === "internal" && mapping.internalField) {
val = formData[mapping.internalField] as string;
}
if (val && typeof val === "string" && labelData[val]) {
newOptionsMap[col.field] = [{ value: val, label: labelData[val] }];
}
}
if (Object.keys(newOptionsMap).length > 0) {
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
}
}
} catch {
// 라벨 조회 실패 시 무시
}
};
loadParentLabels();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData, tableConfig.columns]);
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우) // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => { useEffect(() => {
if (!isConditionalMode) return; if (!isConditionalMode) return;
@ -1005,6 +1068,23 @@ export function TableSectionRenderer({
}); });
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]); }, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생
const tableCategoryColumns = useMemo(() => {
return Object.keys(categoryOptionsMap);
}, [categoryOptionsMap]);
const tableCategoryLabelMap = useMemo(() => {
const map: Record<string, string> = {};
for (const options of Object.values(categoryOptionsMap)) {
for (const opt of options) {
if (opt.value && opt.label) {
map[opt.value] = opt.label;
}
}
}
return map;
}, [categoryOptionsMap]);
// 원본 계산 규칙 (조건부 계산 포함) // 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo( const originalCalculationRules: TableCalculationRule[] = useMemo(
() => tableConfig.calculations || [], () => tableConfig.calculations || [],
@ -1312,6 +1392,67 @@ export function TableSectionRenderer({
}), }),
); );
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
const categoryFields = (tableConfig.columns || [])
.filter((col) => col.type === "category" || col.type === "select")
.reduce<Record<string, Record<string, string>>>((acc, col) => {
const options = categoryOptionsMap[col.field];
if (options && options.length > 0) {
acc[col.field] = {};
for (const opt of options) {
acc[col.field][opt.value] = opt.label;
}
}
return acc;
}, {});
// receiveFromParent / internal 매핑으로 넘어온 값도 포함하여 변환
if (Object.keys(categoryFields).length > 0) {
for (const item of mappedItems) {
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
const val = item[field];
if (typeof val === "string" && codeToLabel[val]) {
item[field] = codeToLabel[val];
}
}
}
}
// categoryOptionsMap에 없는 경우 API fallback
const unresolvedCodes = new Set<string>();
const categoryColFields = new Set(
(tableConfig.columns || []).filter((col) => col.type === "category").map((col) => col.field),
);
for (const item of mappedItems) {
for (const field of categoryColFields) {
const val = item[field];
if (typeof val === "string" && val && !categoryFields[field]?.[val] && val !== item[field]) {
unresolvedCodes.add(val);
}
}
}
if (unresolvedCodes.size > 0) {
try {
const labelResp = await apiClient.post("/table-categories/labels-by-codes", {
valueCodes: Array.from(unresolvedCodes),
});
if (labelResp.data?.success && labelResp.data.data) {
const labelData = labelResp.data.data as Record<string, string>;
for (const item of mappedItems) {
for (const field of categoryColFields) {
const val = item[field];
if (typeof val === "string" && labelData[val]) {
item[field] = labelData[val];
}
}
}
}
} catch {
// 변환 실패 시 코드 유지
}
}
// 계산 필드 업데이트 // 계산 필드 업데이트
const calculatedItems = calculateAll(mappedItems); const calculatedItems = calculateAll(mappedItems);
@ -1319,7 +1460,7 @@ export function TableSectionRenderer({
const newData = [...tableData, ...calculatedItems]; const newData = [...tableData, ...calculatedItems];
handleDataChange(newData); handleDataChange(newData);
}, },
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources], [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources, categoryOptionsMap],
); );
// 컬럼 모드/조회 옵션 변경 핸들러 // 컬럼 모드/조회 옵션 변경 핸들러
@ -1667,6 +1808,31 @@ export function TableSectionRenderer({
}), }),
); );
// 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용)
const categoryFields = (tableConfig.columns || [])
.filter((col) => col.type === "category" || col.type === "select")
.reduce<Record<string, Record<string, string>>>((acc, col) => {
const options = categoryOptionsMap[col.field];
if (options && options.length > 0) {
acc[col.field] = {};
for (const opt of options) {
acc[col.field][opt.value] = opt.label;
}
}
return acc;
}, {});
if (Object.keys(categoryFields).length > 0) {
for (const item of mappedItems) {
for (const [field, codeToLabel] of Object.entries(categoryFields)) {
const val = item[field];
if (typeof val === "string" && codeToLabel[val]) {
item[field] = codeToLabel[val];
}
}
}
}
// 현재 조건의 데이터에 추가 // 현재 조건의 데이터에 추가
const currentData = conditionalTableData[modalCondition] || []; const currentData = conditionalTableData[modalCondition] || [];
const newData = [...currentData, ...mappedItems]; const newData = [...currentData, ...mappedItems];
@ -1964,6 +2130,8 @@ export function TableSectionRenderer({
[conditionValue]: newSelected, [conditionValue]: newSelected,
})); }));
}} }}
categoryColumns={tableCategoryColumns}
categoryLabelMap={tableCategoryLabelMap}
equalizeWidthsTrigger={widthTrigger} equalizeWidthsTrigger={widthTrigger}
/> />
</TabsContent> </TabsContent>
@ -2055,6 +2223,8 @@ export function TableSectionRenderer({
})); }));
}} }}
equalizeWidthsTrigger={widthTrigger} equalizeWidthsTrigger={widthTrigger}
categoryColumns={tableCategoryColumns}
categoryLabelMap={tableCategoryLabelMap}
/> />
</TabsContent> </TabsContent>
); );
@ -2185,6 +2355,8 @@ export function TableSectionRenderer({
selectedRows={selectedRows} selectedRows={selectedRows}
onSelectionChange={setSelectedRows} onSelectionChange={setSelectedRows}
equalizeWidthsTrigger={widthTrigger} equalizeWidthsTrigger={widthTrigger}
categoryColumns={tableCategoryColumns}
categoryLabelMap={tableCategoryLabelMap}
/> />
{/* 항목 선택 모달 */} {/* 항목 선택 모달 */}

View File

@ -247,6 +247,10 @@ export function UniversalFormModalComponent({
// 폼 데이터 상태 // 폼 데이터 상태
const [formData, setFormData] = useState<FormDataState>({}); const [formData, setFormData] = useState<FormDataState>({});
// formDataRef: 항상 최신 formData를 유지하는 ref
// React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서
// 클로저의 formData가 오래된 값을 참조하는 문제를 방지
const formDataRef = useRef<FormDataState>({});
const [, setOriginalData] = useState<Record<string, any>>({}); const [, setOriginalData] = useState<Record<string, any>>({});
// 반복 섹션 데이터 // 반복 섹션 데이터
@ -398,18 +402,19 @@ export function UniversalFormModalComponent({
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신"); console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields)); console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
// formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지
const latestFormData = formDataRef.current;
// 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용) // 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용)
// - 신규 등록: formData.id가 없으므로 영향 없음 if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") {
// - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용 event.detail.formData.id = latestFormData.id;
if (formData.id !== undefined && formData.id !== null && formData.id !== "") { console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id);
event.detail.formData.id = formData.id;
console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id);
} }
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함) // UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀 // 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
// (UniversalFormModal이 해당 필드의 주인이므로) // (UniversalFormModal이 해당 필드의 주인이므로)
for (const [key, value] of Object.entries(formData)) { for (const [key, value] of Object.entries(latestFormData)) {
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합 // 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
const isConfiguredField = configuredFields.has(key); const isConfiguredField = configuredFields.has(key);
const isNumberingRuleId = key.endsWith("_numberingRuleId"); const isNumberingRuleId = key.endsWith("_numberingRuleId");
@ -432,17 +437,13 @@ export function UniversalFormModalComponent({
} }
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등) // 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
// 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), // formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장
// handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용 for (const [key, value] of Object.entries(latestFormData)) {
for (const [key, value] of Object.entries(formData)) { // _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달
// 싱글/더블 언더스코어 모두 처리 // buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합
if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) { if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) {
// 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대) event.detail.formData[key] = value;
const normalizedKey = key.startsWith("__tableSection_") console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
? key.replace("__tableSection_", "_tableSection_")
: key;
event.detail.formData[normalizedKey] = value;
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}${normalizedKey}, ${value.length}개 항목`);
} }
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용) // 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
@ -457,6 +458,22 @@ export function UniversalFormModalComponent({
event.detail.formData._originalGroupedData = originalGroupedData; event.detail.formData._originalGroupedData = originalGroupedData;
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}`); console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}`);
} }
// 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트
// onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트
for (const parentKey of Object.keys(event.detail.formData)) {
const parentValue = event.detail.formData[parentKey];
if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) {
const hasTableSection = Object.keys(parentValue).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
);
if (hasTableSection) {
event.detail.formData[parentKey] = { ...latestFormData };
console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`);
break;
}
}
}
}; };
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
@ -482,10 +499,11 @@ export function UniversalFormModalComponent({
// 테이블 섹션 데이터 설정 // 테이블 섹션 데이터 설정
const tableSectionKey = `_tableSection_${tableSection.id}`; const tableSectionKey = `_tableSection_${tableSection.id}`;
setFormData((prev) => ({ setFormData((prev) => {
...prev, const newData = { ...prev, [tableSectionKey]: _groupedData };
[tableSectionKey]: _groupedData, formDataRef.current = newData;
})); return newData;
});
groupedDataInitializedRef.current = true; groupedDataInitializedRef.current = true;
}, [_groupedData, config.sections]); }, [_groupedData, config.sections]);
@ -965,6 +983,7 @@ export function UniversalFormModalComponent({
} }
setFormData(newFormData); setFormData(newFormData);
formDataRef.current = newFormData;
setRepeatSections(newRepeatSections); setRepeatSections(newRepeatSections);
setCollapsedSections(newCollapsed); setCollapsedSections(newCollapsed);
setActivatedOptionalFieldGroups(newActivatedGroups); setActivatedOptionalFieldGroups(newActivatedGroups);
@ -1132,6 +1151,9 @@ export function UniversalFormModalComponent({
console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`); console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`);
} }
// ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능)
formDataRef.current = newData;
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용) // onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
if (onChange) { if (onChange) {
setTimeout(() => onChange(newData), 0); setTimeout(() => onChange(newData), 0);

View File

@ -393,7 +393,7 @@ export interface TableModalFilter {
export interface TableColumnConfig { export interface TableColumnConfig {
field: string; // 필드명 (저장할 컬럼명) field: string; // 필드명 (저장할 컬럼명)
label: string; // 컬럼 헤더 라벨 label: string; // 컬럼 헤더 라벨
type: "text" | "number" | "date" | "select"; // 입력 타입 type: "text" | "number" | "date" | "select" | "category"; // 입력 타입
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명) // 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일) sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { import {
GripVertical, GripVertical,
Plus, Plus,
@ -13,6 +13,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -26,6 +27,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogFooter,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@ -82,7 +84,7 @@ const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`;
interface ItemSearchModalProps { interface ItemSearchModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onSelect: (item: ItemInfo) => void; onSelect: (items: ItemInfo[]) => void;
companyCode?: string; companyCode?: string;
} }
@ -94,6 +96,7 @@ function ItemSearchModal({
}: ItemSearchModalProps) { }: ItemSearchModalProps) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [items, setItems] = useState<ItemInfo[]>([]); const [items, setItems] = useState<ItemInfo[]>([]);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const searchItems = useCallback( const searchItems = useCallback(
@ -109,7 +112,7 @@ function ItemSearchModal({
enableEntityJoin: true, enableEntityJoin: true,
companyCodeOverride: companyCode, companyCodeOverride: companyCode,
}); });
setItems(result.data || []); setItems((result.data || []) as ItemInfo[]);
} catch (error) { } catch (error) {
console.error("[BomItemEditor] 품목 검색 실패:", error); console.error("[BomItemEditor] 품목 검색 실패:", error);
} finally { } finally {
@ -122,6 +125,7 @@ function ItemSearchModal({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSearchText(""); setSearchText("");
setSelectedItems(new Set());
searchItems(""); searchItems("");
} }
}, [open, searchItems]); }, [open, searchItems]);
@ -180,6 +184,15 @@ function ItemSearchModal({
<table className="w-full text-xs sm:text-sm"> <table className="w-full text-xs sm:text-sm">
<thead className="bg-muted/50 sticky top-0"> <thead className="bg-muted/50 sticky top-0">
<tr> <tr>
<th className="w-8 px-2 py-2 text-center">
<Checkbox
checked={selectedItems.size > 0 && selectedItems.size === items.length}
onCheckedChange={(checked) => {
if (checked) setSelectedItems(new Set(items.map((i) => i.id)));
else setSelectedItems(new Set());
}}
/>
</th>
<th className="px-3 py-2 text-left font-medium"></th> <th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th> <th className="px-3 py-2 text-left font-medium"></th>
<th className="px-3 py-2 text-left font-medium"></th> <th className="px-3 py-2 text-left font-medium"></th>
@ -191,11 +204,31 @@ function ItemSearchModal({
<tr <tr
key={item.id} key={item.id}
onClick={() => { onClick={() => {
onSelect(item); setSelectedItems((prev) => {
onClose(); const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id);
else next.add(item.id);
return next;
});
}} }}
className="hover:bg-accent cursor-pointer border-t transition-colors" className={cn(
"cursor-pointer border-t transition-colors",
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent",
)}
> >
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedItems.has(item.id)}
onCheckedChange={(checked) => {
setSelectedItems((prev) => {
const next = new Set(prev);
if (checked) next.add(item.id);
else next.delete(item.id);
return next;
});
}}
/>
</td>
<td className="px-3 py-2 font-mono"> <td className="px-3 py-2 font-mono">
{item.item_number} {item.item_number}
</td> </td>
@ -208,6 +241,25 @@ function ItemSearchModal({
</table> </table>
)} )}
</div> </div>
{selectedItems.size > 0 && (
<DialogFooter className="gap-2 sm:gap-0">
<span className="text-muted-foreground text-xs sm:text-sm">
{selectedItems.size}
</span>
<Button
onClick={() => {
const selected = items.filter((i) => selectedItems.has(i.id));
onSelect(selected);
onClose();
}}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</DialogFooter>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@ -227,6 +279,10 @@ interface TreeNodeRowProps {
onFieldChange: (tempId: string, field: string, value: string) => void; onFieldChange: (tempId: string, field: string, value: string) => void;
onDelete: (tempId: string) => void; onDelete: (tempId: string) => void;
onAddChild: (parentTempId: string) => void; onAddChild: (parentTempId: string) => void;
onDragStart: (e: React.DragEvent, tempId: string) => void;
onDragOver: (e: React.DragEvent, tempId: string) => void;
onDrop: (e: React.DragEvent, tempId: string) => void;
isDragOver?: boolean;
} }
function TreeNodeRow({ function TreeNodeRow({
@ -241,6 +297,10 @@ function TreeNodeRow({
onFieldChange, onFieldChange,
onDelete, onDelete,
onAddChild, onAddChild,
onDragStart,
onDragOver,
onDrop,
isDragOver,
}: TreeNodeRowProps) { }: TreeNodeRowProps) {
const indentPx = depth * 32; const indentPx = depth * 32;
const visibleColumns = columns.filter((c) => c.visible !== false); const visibleColumns = columns.filter((c) => c.visible !== false);
@ -319,8 +379,13 @@ function TreeNodeRow({
"group flex items-center gap-2 rounded-md border px-2 py-1.5", "group flex items-center gap-2 rounded-md border px-2 py-1.5",
"transition-colors hover:bg-accent/30", "transition-colors hover:bg-accent/30",
depth > 0 && "ml-2 border-l-2 border-l-primary/20", depth > 0 && "ml-2 border-l-2 border-l-primary/20",
isDragOver && "border-primary bg-primary/5 border-dashed",
)} )}
style={{ marginLeft: `${indentPx}px` }} style={{ marginLeft: `${indentPx}px` }}
draggable
onDragStart={(e) => onDragStart(e, node.tempId)}
onDragOver={(e) => onDragOver(e, node.tempId)}
onDrop={(e) => onDrop(e, node.tempId)}
> >
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" /> <GripVertical className="text-muted-foreground h-4 w-4 shrink-0 cursor-grab" />
@ -409,7 +474,7 @@ export function BomItemEditorComponent({
// 설정값 추출 // 설정값 추출
const cfg = useMemo(() => component?.componentConfig || {}, [component]); const cfg = useMemo(() => component?.componentConfig || {}, [component]);
const mainTableName = cfg.mainTableName || "bom_detail"; const mainTableName = cfg.mainTableName || "bom_detail";
const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id"; const parentKeyColumn = (cfg.parentKeyColumn && cfg.parentKeyColumn !== "id") ? cfg.parentKeyColumn : "parent_detail_id";
const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]); const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]);
const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]); const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]);
const fkColumn = cfg.foreignKeyColumn || "bom_id"; const fkColumn = cfg.foreignKeyColumn || "bom_id";
@ -422,6 +487,28 @@ export function BomItemEditorComponent({
return null; return null;
}, [propBomId, formData, selectedRowsData]); }, [propBomId, formData, selectedRowsData]);
// BOM 전용 API로 현재 current_version_id 조회
const fetchCurrentVersionId = useCallback(async (id: string): Promise<string | null> => {
try {
const res = await apiClient.get(`/bom/${id}/versions`);
if (res.data?.success) {
// bom.current_version_id를 직접 반환 (불러오기와 사용확정 구분)
if (res.data.currentVersionId) return res.data.currentVersionId;
// fallback: active 상태 버전
const activeVersion = res.data.data?.find((v: any) => v.status === "active");
if (activeVersion) return activeVersion.id;
}
} catch (e) {
console.error("[BomItemEditor] current_version_id 조회 실패:", e);
}
return null;
}, []);
// formData에서 가져오는 versionId (fallback용)
const propsVersionId = (formData?.current_version_id as string)
|| (selectedRowsData?.[0]?.current_version_id as string)
|| null;
// ─── 카테고리 옵션 로드 (리피터 방식) ─── // ─── 카테고리 옵션 로드 (리피터 방식) ───
useEffect(() => { useEffect(() => {
@ -431,7 +518,14 @@ export function BomItemEditorComponent({
for (const col of categoryColumns) { for (const col of categoryColumns) {
const categoryRef = `${mainTableName}.${col.key}`; const categoryRef = `${mainTableName}.${col.key}`;
if (categoryOptionsMap[categoryRef]) continue;
const alreadyLoaded = await new Promise<boolean>((resolve) => {
setCategoryOptionsMap((prev) => {
resolve(!!prev[categoryRef]);
return prev;
});
});
if (alreadyLoaded) continue;
try { try {
const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`); const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`);
@ -455,21 +549,58 @@ export function BomItemEditorComponent({
// ─── 데이터 로드 ─── // ─── 데이터 로드 ───
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
const sourceTable = cfg.dataSource?.sourceTable || "item_info";
const loadBomDetails = useCallback( const loadBomDetails = useCallback(
async (id: string) => { async (id: string) => {
if (!id) return; if (!id) return;
setLoading(true); setLoading(true);
try { try {
const result = await entityJoinApi.getTableDataWithJoins(mainTableName, { // isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청
page: 1, const displayCols = columns.filter((c) => c.isSourceDisplay);
size: 500, const additionalJoinColumns = displayCols.map((col) => ({
search: { [fkColumn]: id }, sourceTable,
sortBy: "seq_no", sourceColumn: sourceFk,
sortOrder: "asc", joinAlias: `${sourceFk}_${col.key}`,
enableEntityJoin: true, referenceTable: sourceTable,
}));
// 서버에서 최신 current_version_id 조회 (항상 최신 보장)
const freshVersionId = await fetchCurrentVersionId(id);
const effectiveVersionId = freshVersionId || propsVersionId;
const searchFilter: Record<string, any> = { [fkColumn]: id };
if (effectiveVersionId) {
searchFilter.version_id = effectiveVersionId;
}
// autoFilter 비활성화: BOM 전용 API로 company_code 관리
const res = await apiClient.get(`/table-management/tables/${mainTableName}/data-with-joins`, {
params: {
page: 1,
size: 500,
search: JSON.stringify(searchFilter),
sortBy: "seq_no",
sortOrder: "asc",
enableEntityJoin: true,
additionalJoinColumns: additionalJoinColumns.length > 0 ? JSON.stringify(additionalJoinColumns) : undefined,
autoFilter: JSON.stringify({ enabled: false }),
},
});
const rawData = res.data?.data?.data || res.data?.data || [];
const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record<string, any>) => {
const mapped = { ...row };
for (const key of Object.keys(row)) {
if (key.startsWith(`${sourceFk}_`)) {
const shortKey = key.replace(`${sourceFk}_`, "");
if (!mapped[shortKey]) mapped[shortKey] = row[key];
}
}
return mapped;
}); });
const rows = result.data || [];
const tree = buildTree(rows); const tree = buildTree(rows);
setTreeData(tree); setTreeData(tree);
@ -483,14 +614,20 @@ export function BomItemEditorComponent({
setLoading(false); setLoading(false);
} }
}, },
[mainTableName, fkColumn], [mainTableName, fkColumn, sourceFk, sourceTable, columns, fetchCurrentVersionId, propsVersionId],
); );
// formData.current_version_id가 변경될 때도 재로드 (버전 전환 시 반영)
const formVersionRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (bomId && !isDesignMode) { if (!bomId || isDesignMode) return;
const currentFormVersion = formData?.current_version_id as string || null;
// bomId가 바뀌거나, formData의 current_version_id가 바뀌면 재로드
if (formVersionRef.current !== currentFormVersion || !formVersionRef.current) {
formVersionRef.current = currentFormVersion;
loadBomDetails(bomId); loadBomDetails(bomId);
} }
}, [bomId, isDesignMode, loadBomDetails]); }, [bomId, isDesignMode, loadBomDetails, formData?.current_version_id]);
// ─── 트리 빌드 (동적 데이터) ─── // ─── 트리 빌드 (동적 데이터) ───
@ -548,10 +685,13 @@ export function BomItemEditorComponent({
id: node.id, id: node.id,
tempId: node.tempId, tempId: node.tempId,
[parentKeyColumn]: parentId, [parentKeyColumn]: parentId,
[fkColumn]: bomId,
seq_no: String(idx + 1), seq_no: String(idx + 1),
level: String(level), level: String(level),
_isNew: node._isNew, _isNew: node._isNew,
_targetTable: mainTableName, _targetTable: mainTableName,
_fkColumn: fkColumn,
_deferSave: true,
}); });
if (node.children.length > 0) { if (node.children.length > 0) {
traverse(node.children, node.id || node.tempId, level + 1); traverse(node.children, node.id || node.tempId, level + 1);
@ -560,7 +700,7 @@ export function BomItemEditorComponent({
}; };
traverse(nodes, null, 0); traverse(nodes, null, 0);
return result; return result;
}, [parentKeyColumn, mainTableName]); }, [parentKeyColumn, mainTableName, fkColumn, bomId]);
// 트리 변경 시 부모에게 알림 // 트리 변경 시 부모에게 알림
const notifyChange = useCallback( const notifyChange = useCallback(
@ -571,6 +711,164 @@ export function BomItemEditorComponent({
[onChange, flattenTree], [onChange, flattenTree],
); );
// ─── DB 저장 (INSERT/UPDATE/DELETE 일괄) ───
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const originalDataRef = React.useRef<Set<string>>(new Set());
useEffect(() => {
if (treeData.length > 0 && originalDataRef.current.size === 0) {
const collectIds = (nodes: BomItemNode[]) => {
nodes.forEach((n) => {
if (n.id) originalDataRef.current.add(n.id);
collectIds(n.children);
});
};
collectIds(treeData);
}
}, [treeData]);
const markChanged = useCallback(() => setHasChanges(true), []);
const originalNotifyChange = notifyChange;
const notifyChangeWithDirty = useCallback(
(newTree: BomItemNode[]) => {
originalNotifyChange(newTree);
markChanged();
},
[originalNotifyChange, markChanged],
);
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
useEffect(() => {
if (isDesignMode || !bomId) return;
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", {
bomId,
treeDataLength: treeData.length,
hasRef: !!handleSaveAllRef.current,
});
if (treeData.length > 0 && handleSaveAllRef.current) {
const savePromise = handleSaveAllRef.current();
if (detail?.pendingPromises) {
detail.pendingPromises.push(savePromise);
console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료");
}
}
};
window.addEventListener("beforeFormSave", handler);
console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode });
return () => window.removeEventListener("beforeFormSave", handler);
}, [isDesignMode, bomId, treeData.length]);
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
const handleSaveAll = useCallback(async () => {
if (!bomId) return;
setSaving(true);
try {
// 저장 시점에도 최신 version_id 조회
const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
const result: any[] = [];
nodes.forEach((node, idx) => {
result.push({
node,
parentRealId,
level,
seqNo: idx + 1,
});
if (node.children.length > 0) {
result.push(...collectAll(node.children, node.id || node.tempId, level + 1));
}
});
return result;
};
const allNodes = collectAll(treeData, null, 0);
const tempToReal: Record<string, string> = {};
let savedCount = 0;
for (const { node, parentRealId, level, seqNo } of allNodes) {
const realParentId = parentRealId
? tempToReal[parentRealId] || parentRealId
: null;
if (node._isNew) {
const payload: Record<string, any> = {
...node.data,
[fkColumn]: bomId,
[parentKeyColumn]: realParentId,
seq_no: String(seqNo),
level: String(level),
company_code: companyCode || undefined,
version_id: saveVersionId || undefined,
};
delete payload.id;
delete payload.tempId;
delete payload._isNew;
delete payload._isDeleted;
const resp = await apiClient.post(
`/table-management/tables/${mainTableName}/add`,
payload,
);
const newId = resp.data?.data?.id;
if (newId) tempToReal[node.tempId] = newId;
savedCount++;
} else if (node.id) {
const updatedData: Record<string, any> = {
...node.data,
id: node.id,
[parentKeyColumn]: realParentId,
seq_no: String(seqNo),
level: String(level),
};
delete updatedData.tempId;
delete updatedData._isNew;
delete updatedData._isDeleted;
Object.keys(updatedData).forEach((k) => {
if (k.startsWith(`${sourceFk}_`)) delete updatedData[k];
});
await apiClient.put(
`/table-management/tables/${mainTableName}/edit`,
{ originalData: { id: node.id }, updatedData },
);
savedCount++;
}
}
const currentIds = new Set(allNodes.filter((a) => a.node.id).map((a) => a.node.id));
for (const oldId of originalDataRef.current) {
if (!currentIds.has(oldId)) {
await apiClient.delete(
`/table-management/tables/${mainTableName}/delete`,
{ data: [{ id: oldId }] },
);
savedCount++;
}
}
originalDataRef.current = new Set(allNodes.filter((a) => a.node.id || tempToReal[a.node.tempId]).map((a) => a.node.id || tempToReal[a.node.tempId]));
setHasChanges(false);
if (bomId) loadBomDetails(bomId);
window.dispatchEvent(new CustomEvent("refreshTable"));
console.log(`[BomItemEditor] ${savedCount}건 저장 완료`);
} catch (error) {
console.error("[BomItemEditor] 저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
} finally {
setSaving(false);
}
}, [bomId, treeData, fkColumn, parentKeyColumn, mainTableName, companyCode, sourceFk, loadBomDetails, fetchCurrentVersionId, propsVersionId]);
useEffect(() => {
handleSaveAllRef.current = handleSaveAll;
}, [handleSaveAll]);
// ─── 노드 조작 함수들 ─── // ─── 노드 조작 함수들 ───
// 트리에서 특정 노드 찾기 (재귀) // 트리에서 특정 노드 찾기 (재귀)
@ -601,18 +899,18 @@ export function BomItemEditorComponent({
...node, ...node,
data: { ...node.data, [field]: value }, data: { ...node.data, [field]: value },
})); }));
notifyChange(newTree); notifyChangeWithDirty(newTree);
}, },
[treeData, notifyChange], [treeData, notifyChangeWithDirty],
); );
// 노드 삭제 // 노드 삭제
const handleDelete = useCallback( const handleDelete = useCallback(
(tempId: string) => { (tempId: string) => {
const newTree = findAndUpdate(treeData, tempId, () => null); const newTree = findAndUpdate(treeData, tempId, () => null);
notifyChange(newTree); notifyChangeWithDirty(newTree);
}, },
[treeData, notifyChange], [treeData, notifyChangeWithDirty],
); );
// 하위 품목 추가 시작 (모달 열기) // 하위 품목 추가 시작 (모달 열기)
@ -627,59 +925,62 @@ export function BomItemEditorComponent({
setItemSearchOpen(true); setItemSearchOpen(true);
}, []); }, []);
// 품목 선택 후 추가 (동적 데이터) // 품목 선택 후 추가 (다중 선택 지원)
const handleItemSelect = useCallback( const handleItemSelect = useCallback(
(item: ItemInfo) => { (selectedItemsList: ItemInfo[]) => {
// 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식) let newTree = [...treeData];
const sourceData: Record<string, any> = {};
const sourceTable = cfg.dataSource?.sourceTable; for (const item of selectedItemsList) {
if (sourceTable) { const sourceData: Record<string, any> = {};
const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; const sourceTable = cfg.dataSource?.sourceTable;
sourceData[sourceFk] = item.id; if (sourceTable) {
// 소스 표시 컬럼의 데이터 병합 const sourceFk = cfg.dataSource?.foreignKey || "child_item_id";
Object.keys(item).forEach((key) => { sourceData[sourceFk] = item.id;
sourceData[`_display_${key}`] = (item as any)[key]; Object.keys(item).forEach((key) => {
sourceData[key] = (item as any)[key]; sourceData[`_display_${key}`] = (item as any)[key];
}); sourceData[key] = (item as any)[key];
});
}
const newNode: BomItemNode = {
tempId: generateTempId(),
parent_detail_id: null,
seq_no: 0,
level: 0,
children: [],
_isNew: true,
data: {
...sourceData,
quantity: "1",
loss_rate: "0",
remark: "",
},
};
if (addTargetParentId === null) {
newNode.seq_no = newTree.length + 1;
newNode.level = 0;
newTree = [...newTree, newNode];
} else {
newTree = findAndUpdate(newTree, addTargetParentId, (parent) => {
newNode.parent_detail_id = parent.id || parent.tempId;
newNode.seq_no = parent.children.length + 1;
newNode.level = parent.level + 1;
return {
...parent,
children: [...parent.children, newNode],
};
});
}
} }
const newNode: BomItemNode = { if (addTargetParentId !== null) {
tempId: generateTempId(),
parent_detail_id: null,
seq_no: 0,
level: 0,
children: [],
_isNew: true,
data: {
...sourceData,
quantity: "1",
loss_rate: "0",
remark: "",
},
};
let newTree: BomItemNode[];
if (addTargetParentId === null) {
newNode.seq_no = treeData.length + 1;
newNode.level = 0;
newTree = [...treeData, newNode];
} else {
newTree = findAndUpdate(treeData, addTargetParentId, (parent) => {
newNode.parent_detail_id = parent.id || parent.tempId;
newNode.seq_no = parent.children.length + 1;
newNode.level = parent.level + 1;
return {
...parent,
children: [...parent.children, newNode],
};
});
setExpandedNodes((prev) => new Set([...prev, addTargetParentId])); setExpandedNodes((prev) => new Set([...prev, addTargetParentId]));
} }
notifyChange(newTree); notifyChangeWithDirty(newTree);
}, },
[addTargetParentId, treeData, notifyChange, cfg], [addTargetParentId, treeData, notifyChangeWithDirty, cfg],
); );
// 펼침/접기 토글 // 펼침/접기 토글
@ -692,6 +993,101 @@ export function BomItemEditorComponent({
}); });
}, []); }, []);
// ─── 드래그 재정렬 ───
const [dragId, setDragId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
// 트리에서 노드를 제거하고 반환
const removeNode = (nodes: BomItemNode[], tempId: string): { tree: BomItemNode[]; removed: BomItemNode | null } => {
const result: BomItemNode[] = [];
let removed: BomItemNode | null = null;
for (const node of nodes) {
if (node.tempId === tempId) {
removed = node;
} else {
const childResult = removeNode(node.children, tempId);
if (childResult.removed) removed = childResult.removed;
result.push({ ...node, children: childResult.tree });
}
}
return { tree: result, removed };
};
// 노드가 대상의 자손인지 확인 (자기 자신의 하위로 드래그 방지)
const isDescendant = (nodes: BomItemNode[], parentId: string, childId: string): boolean => {
const find = (list: BomItemNode[]): BomItemNode | null => {
for (const n of list) {
if (n.tempId === parentId) return n;
const found = find(n.children);
if (found) return found;
}
return null;
};
const parent = find(nodes);
if (!parent) return false;
const check = (children: BomItemNode[]): boolean => {
for (const c of children) {
if (c.tempId === childId) return true;
if (check(c.children)) return true;
}
return false;
};
return check(parent.children);
};
const handleDragStart = useCallback((e: React.DragEvent, tempId: string) => {
setDragId(tempId);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", tempId);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, tempId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverId(tempId);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetTempId: string) => {
e.preventDefault();
setDragOverId(null);
if (!dragId || dragId === targetTempId) return;
// 자기 자신의 하위로 드래그 방지
if (isDescendant(treeData, dragId, targetTempId)) return;
const { tree: treeWithout, removed } = removeNode(treeData, dragId);
if (!removed) return;
// 대상 노드 바로 뒤에 같은 레벨로 삽입
const insertAfter = (nodes: BomItemNode[], afterId: string, node: BomItemNode): { result: BomItemNode[]; inserted: boolean } => {
const result: BomItemNode[] = [];
let inserted = false;
for (const n of nodes) {
result.push(n);
if (n.tempId === afterId) {
result.push({ ...node, level: n.level, parent_detail_id: n.parent_detail_id });
inserted = true;
} else if (!inserted) {
const childResult = insertAfter(n.children, afterId, node);
if (childResult.inserted) {
result[result.length - 1] = { ...n, children: childResult.result };
inserted = true;
}
}
}
return { result, inserted };
};
const { result, inserted } = insertAfter(treeWithout, targetTempId, removed);
if (inserted) {
const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] =>
nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) }));
notifyChangeWithDirty(reindex(result));
}
setDragId(null);
}, [dragId, treeData, notifyChangeWithDirty]);
// ─── 재귀 렌더링 ─── // ─── 재귀 렌더링 ───
const renderNodes = (nodes: BomItemNode[], depth: number) => { const renderNodes = (nodes: BomItemNode[], depth: number) => {
@ -711,6 +1107,10 @@ export function BomItemEditorComponent({
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
onDelete={handleDelete} onDelete={handleDelete}
onAddChild={handleAddChild} onAddChild={handleAddChild}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
isDragOver={dragOverId === node.tempId}
/> />
{isExpanded && {isExpanded &&
node.children.length > 0 && node.children.length > 0 &&
@ -886,19 +1286,33 @@ export function BomItemEditorComponent({
<div className="space-y-3"> <div className="space-y-3">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> </h4> <h4 className="text-sm font-semibold">
<Button
onClick={handleAddRoot} {hasChanges && <span className="ml-1.5 text-[10px] text-amber-500">()</span>}
size="sm" </h4>
className="h-8 text-xs" <div className="flex gap-1.5">
> <Button
<Plus className="mr-1 h-3.5 w-3.5" /> onClick={handleAddRoot}
variant="outline"
</Button> size="sm"
className="h-7 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
onClick={handleSaveAll}
disabled={saving || !hasChanges}
size="sm"
className="h-7 text-xs"
>
{saving ? "저장중..." : "저장"}
</Button>
</div>
</div> </div>
{/* 트리 목록 */} {/* 트리 목록 */}
<div className="space-y-1"> <div className="max-h-[400px] space-y-1 overflow-y-auto">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<span className="text-muted-foreground text-sm"> ...</span> <span className="text-muted-foreground text-sm"> ...</span>

View File

@ -0,0 +1,212 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
import { apiClient } from "@/lib/api/client";
interface BomDetailEditModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
node: Record<string, any> | null;
isRootNode?: boolean;
tableName: string;
onSaved?: () => void;
}
export function BomDetailEditModal({
open,
onOpenChange,
node,
isRootNode = false,
tableName,
onSaved,
}: BomDetailEditModalProps) {
const [formData, setFormData] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (node && open) {
if (isRootNode) {
setFormData({
base_qty: node.base_qty || "",
unit: node.unit || "",
remark: node.remark || "",
});
} else {
setFormData({
quantity: node.quantity || "",
unit: node.unit || node.detail_unit || "",
process_type: node.process_type || "",
base_qty: node.base_qty || "",
loss_rate: node.loss_rate || "",
remark: node.remark || "",
});
}
}
}, [node, open, isRootNode]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (!node) return;
setSaving(true);
try {
const targetTable = isRootNode ? "bom" : tableName;
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData);
onSaved?.();
onOpenChange(false);
} catch (error) {
console.error("[BomDetailEdit] 저장 실패:", error);
} finally {
setSaving(false);
}
};
if (!node) return null;
const itemCode = isRootNode
? node.child_item_code || node.item_code || node.bom_number || "-"
: node.child_item_code || "-";
const itemName = isRootNode
? node.child_item_name || node.item_name || "-"
: node.child_item_name || "-";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{isRootNode ? "BOM 헤더 수정" : "품목 수정"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{isRootNode
? "BOM 기본 정보를 수정합니다"
: "선택한 품목의 BOM 구성 정보를 수정합니다"}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input value={itemCode} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input value={itemName} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm">
{isRootNode ? "기준수량" : "구성수량"} *
</Label>
<Input
type="number"
value={isRootNode ? formData.base_qty : formData.quantity}
onChange={(e) => handleChange(isRootNode ? "base_qty" : "quantity", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.unit}
onChange={(e) => handleChange("unit", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
{!isRootNode && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.process_type}
onChange={(e) => handleChange("process_type", e.target.value)}
placeholder="예: 조립공정"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> (%)</Label>
<Input
type="number"
value={formData.loss_rate}
onChange={(e) => handleChange("loss_rate", e.target.value)}
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={node.child_specification || node.specification || "-"}
disabled
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={node.child_material || node.material || "-"}
disabled
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
</>
)}
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={formData.remark}
onChange={(e) => handleChange("remark", e.target.value)}
placeholder="비고 사항을 입력하세요"
className="mt-1 min-h-[60px] text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,147 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
interface BomHistoryItem {
id: string;
revision: string;
version: string;
change_type: string;
change_description: string;
changed_by: string;
changed_date: string;
}
interface BomHistoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bomId: string | null;
tableName?: string;
}
const CHANGE_TYPE_STYLE: Record<string, string> = {
"등록": "bg-blue-50 text-blue-600 ring-blue-200",
"수정": "bg-amber-50 text-amber-600 ring-amber-200",
"추가": "bg-emerald-50 text-emerald-600 ring-emerald-200",
"삭제": "bg-red-50 text-red-600 ring-red-200",
};
export function BomHistoryModal({ open, onOpenChange, bomId, tableName = "bom_history" }: BomHistoryModalProps) {
const [history, setHistory] = useState<BomHistoryItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open && bomId) {
loadHistory();
}
}, [open, bomId]);
const loadHistory = async () => {
if (!bomId) return;
setLoading(true);
try {
const res = await apiClient.get(`/bom/${bomId}/history`, { params: { tableName } });
if (res.data?.success) {
setHistory(res.data.data || []);
}
} catch (error) {
console.error("[BomHistory] 로드 실패:", error);
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
try {
return new Date(dateStr).toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[650px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
BOM
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] overflow-auto">
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : history.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-gray-400"> </p>
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr className="border-b">
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}></th>
<th className="px-3 py-2.5 text-left text-[11px] font-semibold text-gray-500"></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}></th>
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "130px" }}></th>
</tr>
</thead>
<tbody>
{history.map((item, idx) => (
<tr key={item.id} className={cn("border-b border-gray-100", idx % 2 === 0 ? "bg-white" : "bg-gray-50/30")}>
<td className="px-3 py-2.5 text-center tabular-nums">{item.revision || "-"}</td>
<td className="px-3 py-2.5 text-center tabular-nums">{item.version || "-"}</td>
<td className="px-3 py-2.5 text-center">
<span className={cn(
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
CHANGE_TYPE_STYLE[item.change_type] || "bg-gray-50 text-gray-500 ring-gray-200",
)}>
{item.change_type}
</span>
</td>
<td className="px-3 py-2.5 text-gray-700">{item.change_description || "-"}</td>
<td className="px-3 py-2.5 text-center text-gray-600">{item.changed_by || "-"}</td>
<td className="px-3 py-2.5 text-center text-gray-400">{formatDate(item.changed_date)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,253 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, Plus, Trash2, Download, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
interface BomVersion {
id: string;
version_name: string;
revision: number;
status: string;
created_by: string;
created_date: string;
}
interface BomVersionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bomId: string | null;
tableName?: string;
detailTable?: string;
onVersionLoaded?: () => void;
}
const STATUS_STYLE: Record<string, { label: string; className: string }> = {
developing: { label: "개발중", className: "bg-red-50 text-red-600 ring-red-200" },
active: { label: "사용", className: "bg-emerald-50 text-emerald-600 ring-emerald-200" },
inactive: { label: "사용중지", className: "bg-gray-100 text-gray-500 ring-gray-200" },
};
export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_version", detailTable = "bom_detail", onVersionLoaded }: BomVersionModalProps) {
const [versions, setVersions] = useState<BomVersion[]>([]);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
useEffect(() => {
if (open && bomId) loadVersions();
}, [open, bomId]);
const loadVersions = async () => {
if (!bomId) return;
setLoading(true);
try {
const res = await apiClient.get(`/bom/${bomId}/versions`, { params: { tableName } });
if (res.data?.success) setVersions(res.data.data || []);
} catch (error) {
console.error("[BomVersion] 로드 실패:", error);
} finally {
setLoading(false);
}
};
const handleCreateVersion = async () => {
if (!bomId) return;
setCreating(true);
try {
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
if (res.data?.success) loadVersions();
} catch (error) {
console.error("[BomVersion] 생성 실패:", error);
} finally {
setCreating(false);
}
};
const handleLoadVersion = async (versionId: string) => {
if (!bomId) return;
setActionId(versionId);
try {
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable });
if (res.data?.success) {
loadVersions();
onVersionLoaded?.();
}
} catch (error) {
console.error("[BomVersion] 불러오기 실패:", error);
} finally {
setActionId(null);
}
};
const handleActivateVersion = async (versionId: string) => {
if (!bomId || !confirm("이 버전을 사용 확정하시겠습니까?\n기존 사용중 버전은 사용중지로 변경됩니다.")) return;
setActionId(versionId);
try {
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/activate`, { tableName });
if (res.data?.success) {
loadVersions();
window.dispatchEvent(new CustomEvent("refreshTable"));
}
} catch (error) {
console.error("[BomVersion] 사용 확정 실패:", error);
} finally {
setActionId(null);
}
};
const handleDeleteVersion = async (versionId: string) => {
if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return;
setActionId(versionId);
try {
const res = await apiClient.delete(`/bom/${bomId}/versions/${versionId}`, { params: { tableName } });
if (res.data?.success) loadVersions();
} catch (error) {
console.error("[BomVersion] 삭제 실패:", error);
} finally {
setActionId(null);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
try {
return new Date(dateStr).toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return dateStr;
}
};
const getStatus = (status: string) => STATUS_STYLE[status] || STATUS_STYLE.inactive;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[550px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
BOM . .
</DialogDescription>
</DialogHeader>
<div className="max-h-[400px] space-y-2 overflow-auto">
{loading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : versions.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-gray-400"> </p>
</div>
) : (
versions.map((ver) => {
const st = getStatus(ver.status);
const isActing = actionId === ver.id;
return (
<div
key={ver.id}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 transition-colors",
ver.status === "active" ? "border-emerald-200 bg-emerald-50/30" : "border-gray-200",
)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900">
Version {ver.version_name}
</span>
<span className={cn(
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
st.className,
)}>
{st.label}
</span>
</div>
<div className="mt-0.5 flex gap-3 text-[11px] text-gray-400">
<span>: {ver.revision}</span>
<span>: {formatDate(ver.created_date)}</span>
{ver.created_by && <span>: {ver.created_by}</span>}
</div>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handleLoadVersion(ver.id)}
disabled={isActing}
className="h-7 gap-1 px-2 text-[10px]"
>
{isActing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
</Button>
{ver.status === "active" ? (
<span className="flex h-7 items-center rounded-md bg-emerald-50 px-2 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-200">
</span>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleActivateVersion(ver.id)}
disabled={isActing}
className="h-7 gap-1 px-2 text-[10px] border-emerald-300 text-emerald-600 hover:bg-emerald-50"
>
<ShieldCheck className="h-3 w-3" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteVersion(ver.id)}
disabled={isActing}
className="h-7 gap-1 px-2 text-[10px]"
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
</div>
);
})
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
onClick={handleCreateVersion}
disabled={creating}
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{creating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Plus className="mr-1 h-4 w-4" />}
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -724,17 +724,28 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
try { try {
// 1. 소스 컴포넌트에서 데이터 가져오기 // 1. 소스 컴포넌트에서 데이터 가져오기
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); let sourceProvider: import("@/types/data-transfer").DataProvidable | undefined;
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 const isAutoSource =
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응) !dataTransferConfig.sourceComponentId || dataTransferConfig.sourceComponentId === "__auto__";
if (!isAutoSource) {
sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
}
// 자동 탐색 모드이거나, 지정된 소스를 찾지 못한 경우
// 현재 마운트된 DataProvider 중에서 table-list를 자동 탐색
if (!sourceProvider) { if (!sourceProvider) {
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); if (!isAutoSource) {
console.log("🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색..."); console.log(
`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`,
);
}
console.log("🔍 [ButtonPrimary] 현재 활성 DataProvider 자동 탐색...");
const allProviders = screenContext.getAllDataProviders(); const allProviders = screenContext.getAllDataProviders();
// 테이블 리스트 우선 탐색 // table-list 우선 탐색
for (const [id, provider] of allProviders) { for (const [id, provider] of allProviders) {
if (provider.componentType === "table-list") { if (provider.componentType === "table-list") {
sourceProvider = provider; sourceProvider = provider;
@ -743,7 +754,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용 // table-list가 없으면 첫 번째 DataProvider 사용
if (!sourceProvider && allProviders.size > 0) { if (!sourceProvider && allProviders.size > 0) {
const firstEntry = allProviders.entries().next().value; const firstEntry = allProviders.entries().next().value;
if (firstEntry) { if (firstEntry) {
@ -784,15 +795,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const additionalValues = additionalProvider.getSelectedData(); const additionalValues = additionalProvider.getSelectedData();
if (additionalValues && additionalValues.length > 0) { if (additionalValues && additionalValues.length > 0) {
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
const firstValue = additionalValues[0]; const firstValue = additionalValues[0];
// fieldName이 지정되어 있으면 그 필드만 추출
if (additionalSource.fieldName) { if (additionalSource.fieldName) {
additionalData[additionalSource.fieldName] = additionalData[additionalSource.fieldName] =
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue; firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
} else { } else {
// fieldName이 없으면 전체 객체 병합
additionalData = { ...additionalData, ...firstValue }; additionalData = { ...additionalData, ...firstValue };
} }
@ -802,6 +810,25 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
value: additionalData[additionalSource.fieldName || "all"], value: additionalData[additionalSource.fieldName || "all"],
}); });
} }
} else if (formData) {
// DataProvider로 등록되지 않은 컴포넌트(v2-select 등)는 formData에서 값을 가져옴
const comp = allComponents?.find((c: any) => c.id === additionalSource.componentId);
const columnName =
comp?.columnName ||
comp?.componentConfig?.columnName ||
comp?.overrides?.columnName;
if (columnName && formData[columnName] !== undefined && formData[columnName] !== "") {
const targetField = additionalSource.fieldName || columnName;
additionalData[targetField] = formData[columnName];
console.log("📦 추가 데이터 수집 (formData 폴백):", {
sourceId: additionalSource.componentId,
columnName,
targetField,
value: formData[columnName],
});
}
} }
} }
} }
@ -870,44 +897,126 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 4. 매핑 규칙 적용 + 추가 데이터 병합 // 4. 매핑 규칙 결정: 멀티 테이블 매핑 또는 레거시 단일 매핑
const mappedData = sourceData.map((row) => { let effectiveMappingRules: any[] = dataTransferConfig.mappingRules || [];
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
const sourceTableName = sourceProvider?.tableName;
const multiTableMappings: Array<{ sourceTable: string; mappingRules: any[] }> =
dataTransferConfig.multiTableMappings || [];
if (multiTableMappings.length > 0 && sourceTableName) {
const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName);
if (matchedGroup) {
effectiveMappingRules = matchedGroup.mappingRules || [];
console.log(`✅ [ButtonPrimary] 멀티 테이블 매핑 적용: ${sourceTableName}`, effectiveMappingRules);
} else {
console.log(`⚠️ [ButtonPrimary] 소스 테이블 ${sourceTableName}에 대한 매핑 없음, 동일 필드명 자동 매핑`);
effectiveMappingRules = [];
}
} else if (multiTableMappings.length > 0 && !sourceTableName) {
console.log("⚠️ [ButtonPrimary] 소스 테이블 미감지, 첫 번째 매핑 그룹 사용");
effectiveMappingRules = multiTableMappings[0]?.mappingRules || [];
}
const mappedData = sourceData.map((row) => {
const mappedRow = applyMappingRules(row, effectiveMappingRules);
// 추가 데이터를 모든 행에 포함
return { return {
...mappedRow, ...mappedRow,
...additionalData, ...additionalData,
}; };
}); });
// 5. targetType / targetComponentId 기본값 및 자동 탐색
const effectiveTargetType = dataTransferConfig.targetType || "component";
let effectiveTargetComponentId = dataTransferConfig.targetComponentId;
// targetComponentId가 없으면 현재 화면에서 DataReceiver 자동 탐색
if (effectiveTargetType === "component" && !effectiveTargetComponentId) {
console.log("🔍 [ButtonPrimary] 타겟 컴포넌트 자동 탐색...");
const allReceivers = screenContext.getAllDataReceivers();
// repeater 계열 우선 탐색
for (const [id, receiver] of allReceivers) {
if (
receiver.componentType === "repeater-field-group" ||
receiver.componentType === "v2-repeater" ||
receiver.componentType === "repeater"
) {
effectiveTargetComponentId = id;
console.log(`✅ [ButtonPrimary] 리피터 자동 발견: ${id} (${receiver.componentType})`);
break;
}
}
// repeater가 없으면 소스가 아닌 첫 번째 DataReceiver 사용
if (!effectiveTargetComponentId) {
for (const [id, receiver] of allReceivers) {
if (receiver.componentType === "table-list" || receiver.componentType === "data-table") {
effectiveTargetComponentId = id;
console.log(`✅ [ButtonPrimary] DataReceiver 자동 발견: ${id} (${receiver.componentType})`);
break;
}
}
}
if (!effectiveTargetComponentId) {
toast.error("데이터를 받을 수 있는 타겟 컴포넌트를 찾을 수 없습니다.");
return;
}
}
console.log("📦 데이터 전달:", { console.log("📦 데이터 전달:", {
sourceData, sourceData,
mappedData, mappedData,
targetType: dataTransferConfig.targetType, targetType: effectiveTargetType,
targetComponentId: dataTransferConfig.targetComponentId, targetComponentId: effectiveTargetComponentId,
targetScreenId: dataTransferConfig.targetScreenId, targetScreenId: dataTransferConfig.targetScreenId,
}); });
// 5. 타겟으로 데이터 전달 // 6. 타겟으로 데이터 전달
if (dataTransferConfig.targetType === "component") { if (effectiveTargetType === "component") {
// 같은 화면의 컴포넌트로 전달 const targetReceiver = screenContext.getDataReceiver(effectiveTargetComponentId);
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
const receiverConfig = {
targetComponentId: effectiveTargetComponentId,
targetComponentType: targetReceiver?.componentType || ("table" as const),
mode: dataTransferConfig.mode || ("append" as const),
mappingRules: dataTransferConfig.mappingRules || [],
};
if (!targetReceiver) { if (!targetReceiver) {
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`); // 타겟이 아직 마운트되지 않은 경우 (조건부 레이어 등)
// 버퍼에 저장하고 레이어 활성화 요청
console.log(
`⏳ [ButtonPrimary] 타겟 컴포넌트 미마운트, 대기열에 추가: ${effectiveTargetComponentId}`,
);
screenContext.addPendingTransfer({
targetComponentId: effectiveTargetComponentId,
data: mappedData,
config: receiverConfig,
timestamp: Date.now(),
targetLayerId: dataTransferConfig.targetLayerId,
});
// 레이어 활성화 이벤트 발행 (page.tsx에서 수신)
const activateEvent = new CustomEvent("activateLayerForComponent", {
detail: {
componentId: effectiveTargetComponentId,
targetLayerId: dataTransferConfig.targetLayerId,
},
});
window.dispatchEvent(activateEvent);
toast.info(`타겟 레이어를 활성화하고 데이터 전달을 준비합니다...`);
return; return;
} }
await targetReceiver.receiveData(mappedData, { await targetReceiver.receiveData(mappedData, receiverConfig);
targetComponentId: dataTransferConfig.targetComponentId,
targetComponentType: targetReceiver.componentType,
mode: dataTransferConfig.mode || "append",
mappingRules: dataTransferConfig.mappingRules || [],
});
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
} else if (dataTransferConfig.targetType === "splitPanel") { } else if (effectiveTargetType === "splitPanel") {
// 🆕 분할 패널의 반대편 화면으로 전달 // 🆕 분할 패널의 반대편 화면으로 전달
if (!splitPanelContext) { if (!splitPanelContext) {
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요."); toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Search, Plus, Trash2, Edit, ListOrdered, Package } from "lucide-react"; import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -51,6 +51,8 @@ export function ItemRoutingComponent({
refreshDetails, refreshDetails,
deleteDetail, deleteDetail,
deleteVersion, deleteVersion,
setDefaultVersion,
unsetDefaultVersion,
} = useItemRouting(configProp || {}); } = useItemRouting(configProp || {});
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
@ -70,16 +72,21 @@ export function ItemRoutingComponent({
}, [fetchItems]); }, [fetchItems]);
// 모달 저장 성공 감지 -> 데이터 새로고침 // 모달 저장 성공 감지 -> 데이터 새로고침
const refreshVersionsRef = React.useRef(refreshVersions);
const refreshDetailsRef = React.useRef(refreshDetails);
refreshVersionsRef.current = refreshVersions;
refreshDetailsRef.current = refreshDetails;
useEffect(() => { useEffect(() => {
const handleSaveSuccess = () => { const handleSaveSuccess = () => {
refreshVersions(); refreshVersionsRef.current();
refreshDetails(); refreshDetailsRef.current();
}; };
window.addEventListener("saveSuccessInModal", handleSaveSuccess); window.addEventListener("saveSuccessInModal", handleSaveSuccess);
return () => { return () => {
window.removeEventListener("saveSuccessInModal", handleSaveSuccess); window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
}; };
}, [refreshVersions, refreshDetails]); }, []);
// 품목 검색 // 품목 검색
const handleSearch = useCallback(() => { const handleSearch = useCallback(() => {
@ -156,6 +163,24 @@ export function ItemRoutingComponent({
[config] [config]
); );
// 기본 버전 토글
const handleToggleDefault = useCallback(
async (versionId: string, currentIsDefault: boolean) => {
let success: boolean;
if (currentIsDefault) {
success = await unsetDefaultVersion(versionId);
if (success) toast({ title: "기본 버전이 해제되었습니다" });
} else {
success = await setDefaultVersion(versionId);
if (success) toast({ title: "기본 버전으로 설정되었습니다" });
}
if (!success) {
toast({ title: "기본 버전 변경 실패", variant: "destructive" });
}
},
[setDefaultVersion, unsetDefaultVersion, toast]
);
// 삭제 확인 // 삭제 확인
const handleConfirmDelete = useCallback(async () => { const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
@ -175,12 +200,6 @@ export function ItemRoutingComponent({
setDeleteTarget(null); setDeleteTarget(null);
}, [deleteTarget, deleteVersion, deleteDetail, toast]); }, [deleteTarget, deleteVersion, deleteDetail, toast]);
// entity join으로 가져온 공정명 컬럼 이름 추정
const processNameKey = useMemo(() => {
const ds = config.dataSource;
return `${ds.processTable}_${ds.processNameColumn}`;
}, [config.dataSource]);
const splitRatio = config.splitRatio || 40; const splitRatio = config.splitRatio || 40;
if (isPreview) { if (isPreview) {
@ -295,34 +314,56 @@ export function ItemRoutingComponent({
<span className="mr-1 text-xs text-muted-foreground">:</span> <span className="mr-1 text-xs text-muted-foreground">:</span>
{versions.map((ver) => { {versions.map((ver) => {
const isActive = selectedVersionId === ver.id; const isActive = selectedVersionId === ver.id;
const isDefault = ver.is_default === true;
return ( return (
<div key={ver.id} className="flex items-center gap-0.5"> <div key={ver.id} className="flex items-center gap-0.5">
<Badge <Badge
variant={isActive ? "default" : "outline"} variant={isActive ? "default" : "outline"}
className={cn( className={cn(
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors", "cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
isActive && "bg-primary text-primary-foreground" isActive && "bg-primary text-primary-foreground",
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700"
)} )}
onClick={() => selectVersion(ver.id)} onClick={() => selectVersion(ver.id)}
> >
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id} {ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
</Badge> </Badge>
{!config.readonly && ( {!config.readonly && (
<Button <>
variant="ghost" <Button
size="icon" variant="ghost"
className="h-5 w-5 text-muted-foreground hover:text-destructive" size="icon"
onClick={(e) => { className={cn(
e.stopPropagation(); "h-5 w-5",
setDeleteTarget({ isDefault
type: "version", ? "text-amber-500 hover:text-amber-600"
id: ver.id, : "text-muted-foreground hover:text-amber-500"
name: ver.version_name || ver.id, )}
}); onClick={(e) => {
}} e.stopPropagation();
> handleToggleDefault(ver.id, isDefault);
<Trash2 className="h-3 w-3" /> }}
</Button> title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}
>
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({
type: "version",
id: ver.id,
name: ver.version_name || ver.id,
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)} )}
</div> </div>
); );
@ -394,11 +435,11 @@ export function ItemRoutingComponent({
<TableRow key={detail.id}> <TableRow key={detail.id}>
{config.processColumns.map((col) => { {config.processColumns.map((col) => {
let cellValue = detail[col.name]; let cellValue = detail[col.name];
if ( if (cellValue == null) {
col.name === "process_code" && const aliasKey = Object.keys(detail).find(
detail[processNameKey] (k) => k.endsWith(`_${col.name}`)
) { );
cellValue = `${detail[col.name]} (${detail[processNameKey]})`; if (aliasKey) cellValue = detail[aliasKey];
} }
return ( return (
<TableCell <TableCell

View File

@ -94,27 +94,29 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[configKey] [configKey]
); );
// 공정 상세 목록 조회 (특정 버전의 공정들) // 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
const fetchDetails = useCallback( const fetchDetails = useCallback(
async (versionId: string) => { async (versionId: string) => {
try { try {
setLoading(true); setLoading(true);
const ds = configRef.current.dataSource; const ds = configRef.current.dataSource;
const res = await apiClient.get("/table-data/entity-join-api/data-with-joins", { const searchConditions = {
params: { [ds.routingDetailFkColumn]: { value: versionId, operator: "equals" },
tableName: ds.routingDetailTable, };
searchConditions: JSON.stringify({ const params = new URLSearchParams({
[ds.routingDetailFkColumn]: { page: "1",
value: versionId, size: "1000",
operator: "equals", search: JSON.stringify(searchConditions),
}, sortBy: "seq_no",
}), sortOrder: "ASC",
sortColumn: "seq_no", enableEntityJoin: "true",
sortDirection: "ASC",
},
}); });
const res = await apiClient.get(
`/table-management/tables/${ds.routingDetailTable}/data-with-joins?${params}`
);
if (res.data?.success) { if (res.data?.success) {
setDetails(res.data.data || []); const result = res.data.data;
setDetails(Array.isArray(result) ? result : result?.data || []);
} }
} catch (err) { } catch (err) {
console.error("공정 상세 조회 실패", err); console.error("공정 상세 조회 실패", err);
@ -136,14 +138,17 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
const versionList = await fetchVersions(itemCode); const versionList = await fetchVersions(itemCode);
// 첫번째 버전 자동 선택 if (versionList.length > 0) {
if (config.autoSelectFirstVersion && versionList.length > 0) { // 기본 버전 우선, 없으면 첫번째 버전 선택
const firstVersion = versionList[0]; const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
setSelectedVersionId(firstVersion.id); const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
await fetchDetails(firstVersion.id); if (targetVersion) {
setSelectedVersionId(targetVersion.id);
await fetchDetails(targetVersion.id);
}
} }
}, },
[fetchVersions, fetchDetails, config.autoSelectFirstVersion] [fetchVersions, fetchDetails]
); );
// 버전 선택 // 버전 선택
@ -181,7 +186,8 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
try { try {
const ds = configRef.current.dataSource; const ds = configRef.current.dataSource;
const res = await apiClient.delete( const res = await apiClient.delete(
`/table-data/${ds.routingDetailTable}/${detailId}` `/table-management/tables/${ds.routingDetailTable}/delete`,
{ data: [{ id: detailId }] }
); );
if (res.data?.success) { if (res.data?.success) {
await refreshDetails(); await refreshDetails();
@ -201,7 +207,8 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
try { try {
const ds = configRef.current.dataSource; const ds = configRef.current.dataSource;
const res = await apiClient.delete( const res = await apiClient.delete(
`/table-data/${ds.routingVersionTable}/${versionId}` `/table-management/tables/${ds.routingVersionTable}/delete`,
{ data: [{ id: versionId }] }
); );
if (res.data?.success) { if (res.data?.success) {
if (selectedVersionId === versionId) { if (selectedVersionId === versionId) {
@ -219,6 +226,51 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[selectedVersionId, refreshVersions] [selectedVersionId, refreshVersions]
); );
// 기본 버전 설정
const setDefaultVersion = useCallback(
async (versionId: string) => {
try {
const ds = configRef.current.dataSource;
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/set-default`, {
routingVersionTable: ds.routingVersionTable,
routingFkColumn: ds.routingVersionFkColumn,
});
if (res.data?.success) {
if (selectedItemCode) {
await fetchVersions(selectedItemCode);
}
return true;
}
} catch (err) {
console.error("기본 버전 설정 실패", err);
}
return false;
},
[selectedItemCode, fetchVersions]
);
// 기본 버전 해제
const unsetDefaultVersion = useCallback(
async (versionId: string) => {
try {
const ds = configRef.current.dataSource;
const res = await apiClient.put(`${API_BASE}/versions/${versionId}/unset-default`, {
routingVersionTable: ds.routingVersionTable,
});
if (res.data?.success) {
if (selectedItemCode) {
await fetchVersions(selectedItemCode);
}
return true;
}
} catch (err) {
console.error("기본 버전 해제 실패", err);
}
return false;
},
[selectedItemCode, fetchVersions]
);
return { return {
config, config,
items, items,
@ -235,5 +287,7 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
refreshDetails, refreshDetails,
deleteDetail, deleteDetail,
deleteVersion, deleteVersion,
setDefaultVersion,
unsetDefaultVersion,
}; };
} }

View File

@ -65,6 +65,7 @@ export interface ItemData {
export interface RoutingVersionData { export interface RoutingVersionData {
id: string; id: string;
version_name: string; version_name: string;
is_default?: boolean;
[key: string]: any; [key: string]: any;
} }

View File

@ -0,0 +1,445 @@
"use client";
import React, { useState, useEffect } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { WorkItemDetail, DetailTypeDefinition, InspectionStandard } from "../types";
import { InspectionStandardLookup } from "./InspectionStandardLookup";
interface DetailFormModalProps {
open: boolean;
onClose: () => void;
onSubmit: (data: Partial<WorkItemDetail>) => void;
detailTypes: DetailTypeDefinition[];
editData?: WorkItemDetail | null;
mode: "add" | "edit";
}
const LOOKUP_TARGETS = [
{ value: "equipment", label: "설비정보" },
{ value: "material", label: "자재정보" },
{ value: "worker", label: "작업자정보" },
{ value: "tool", label: "공구정보" },
{ value: "document", label: "문서정보" },
];
const INPUT_TYPES = [
{ value: "text", label: "텍스트" },
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "textarea", label: "장문텍스트" },
{ value: "select", label: "선택형" },
];
export function DetailFormModal({
open,
onClose,
onSubmit,
detailTypes,
editData,
mode,
}: DetailFormModalProps) {
const [formData, setFormData] = useState<Partial<WorkItemDetail>>({});
const [inspectionLookupOpen, setInspectionLookupOpen] = useState(false);
const [selectedInspection, setSelectedInspection] = useState<InspectionStandard | null>(null);
useEffect(() => {
if (open) {
if (mode === "edit" && editData) {
setFormData({ ...editData });
if (editData.inspection_code) {
setSelectedInspection({
id: "",
inspection_code: editData.inspection_code,
inspection_item: editData.content || "",
inspection_method: editData.inspection_method || "",
unit: editData.unit || "",
lower_limit: editData.lower_limit || "",
upper_limit: editData.upper_limit || "",
});
}
} else {
setFormData({
detail_type: detailTypes[0]?.value || "",
content: "",
is_required: "Y",
});
setSelectedInspection(null);
}
}
}, [open, mode, editData, detailTypes]);
const updateField = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleInspectionSelect = (item: InspectionStandard) => {
setSelectedInspection(item);
setFormData((prev) => ({
...prev,
inspection_code: item.inspection_code,
content: item.inspection_item,
inspection_method: item.inspection_method,
unit: item.unit,
lower_limit: item.lower_limit || "",
upper_limit: item.upper_limit || "",
}));
};
const handleSubmit = () => {
if (!formData.detail_type) return;
const type = formData.detail_type;
if (type === "check" && !formData.content?.trim()) return;
if (type === "inspect" && !formData.content?.trim()) return;
if (type === "procedure" && !formData.content?.trim()) return;
if (type === "input" && !formData.content?.trim()) return;
if (type === "info" && !formData.lookup_target) return;
onSubmit(formData);
onClose();
};
const currentType = formData.detail_type || "";
return (
<>
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{mode === "add" ? "추가" : "수정"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 유형 선택 */}
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Select
value={currentType}
onValueChange={(v) => {
updateField("detail_type", v);
setSelectedInspection(null);
setFormData((prev) => ({
detail_type: v,
is_required: prev.is_required || "Y",
}));
}}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{detailTypes.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 체크리스트 */}
{currentType === "check" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={formData.content || ""}
onChange={(e) => updateField("content", e.target.value)}
placeholder="예: 전원 상태 확인"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</>
)}
{/* 검사항목 */}
{currentType === "inspect" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<div className="mt-1 flex gap-2">
<Select value="_placeholder" disabled>
<SelectTrigger className="h-8 flex-1 text-xs sm:h-10 sm:text-sm">
<SelectValue>
{selectedInspection
? `${selectedInspection.inspection_code} - ${selectedInspection.inspection_item}`
: "검사기준을 선택하세요"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="_placeholder"></SelectItem>
</SelectContent>
</Select>
<Button
variant="secondary"
className="h-8 shrink-0 gap-1 text-xs sm:h-10 sm:text-sm"
onClick={() => setInspectionLookupOpen(true)}
>
<Search className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{selectedInspection && (
<div className="rounded border bg-muted/30 p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<p>
<strong>:</strong> {selectedInspection.inspection_code}
</p>
<p>
<strong>:</strong> {selectedInspection.inspection_item}
</p>
<p>
<strong>:</strong> {selectedInspection.inspection_method || "-"}
</p>
<p>
<strong>:</strong> {selectedInspection.unit || "-"}
</p>
<p>
<strong>:</strong> {selectedInspection.lower_limit || "-"}
</p>
<p>
<strong>:</strong> {selectedInspection.upper_limit || "-"}
</p>
</div>
</div>
)}
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={formData.content || ""}
onChange={(e) => updateField("content", e.target.value)}
placeholder="예: 외경 치수"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.inspection_method || ""}
onChange={(e) => updateField("inspection_method", e.target.value)}
placeholder="예: 마이크로미터"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.unit || ""}
onChange={(e) => updateField("unit", e.target.value)}
placeholder="예: mm"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.lower_limit || ""}
onChange={(e) => updateField("lower_limit", e.target.value)}
placeholder="예: 7.95"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={formData.upper_limit || ""}
onChange={(e) => updateField("upper_limit", e.target.value)}
placeholder="예: 8.05"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
</>
)}
{/* 작업절차 */}
{currentType === "procedure" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={formData.content || ""}
onChange={(e) => updateField("content", e.target.value)}
placeholder="예: 자재 투입"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> ()</Label>
<Input
type="number"
value={formData.duration_minutes ?? ""}
onChange={(e) =>
updateField(
"duration_minutes",
e.target.value ? Number(e.target.value) : undefined
)
}
placeholder="예: 5"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</>
)}
{/* 직접입력 */}
{currentType === "input" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={formData.content || ""}
onChange={(e) => updateField("content", e.target.value)}
placeholder="예: 작업자 의견"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.input_type || "text"}
onValueChange={(v) => updateField("input_type", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{INPUT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* 정보조회 */}
{currentType === "info" && (
<>
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.lookup_target || ""}
onValueChange={(v) => updateField("lookup_target", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{LOOKUP_TARGETS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={formData.display_fields || ""}
onChange={(e) => updateField("display_fields", e.target.value)}
placeholder="예: 설비명, 설비코드"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</>
)}
{/* 필수 여부 (모든 유형 공통) */}
{currentType && (
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={formData.is_required || "Y"}
onValueChange={(v) => updateField("is_required", v)}
>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSubmit}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{mode === "add" ? "추가" : "수정"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<InspectionStandardLookup
open={inspectionLookupOpen}
onClose={() => setInspectionLookupOpen(false)}
onSelect={handleInspectionSelect}
/>
</>
);
}

View File

@ -0,0 +1,187 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { apiClient } from "@/lib/api/client";
import { InspectionStandard } from "../types";
interface InspectionStandardLookupProps {
open: boolean;
onClose: () => void;
onSelect: (item: InspectionStandard) => void;
}
export function InspectionStandardLookup({
open,
onClose,
onSelect,
}: InspectionStandardLookupProps) {
const [data, setData] = useState<any[]>([]);
const [searchText, setSearchText] = useState("");
const [loading, setLoading] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const search: Record<string, any> = {};
if (searchText.trim()) {
search.inspection_item = searchText.trim();
search.inspection_code = searchText.trim();
}
const params = new URLSearchParams({
page: "1",
size: "100",
enableEntityJoin: "true",
...(searchText.trim() ? { search: JSON.stringify(search) } : {}),
});
const res = await apiClient.get(
`/table-management/tables/inspection_standard/data-with-joins?${params}`
);
if (res.data?.success) {
const result = res.data.data;
setData(Array.isArray(result) ? result : result?.data || []);
}
} catch (err) {
console.error("검사기준 조회 실패", err);
} finally {
setLoading(false);
}
}, [searchText]);
useEffect(() => {
if (open) {
fetchData();
}
}, [open, fetchData]);
const handleSelect = (item: any) => {
onSelect({
id: item.id,
inspection_code: item.inspection_code || "",
inspection_item: item.inspection_item || item.inspection_criteria || "",
inspection_method: item.inspection_method || "",
unit: item.unit || "",
lower_limit: item.lower_limit || "",
upper_limit: item.upper_limit || "",
});
onClose();
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<Search className="h-5 w-5" />
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex gap-2">
<Input
placeholder="검사항목명 또는 검사코드로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchData()}
className="h-9 text-sm"
/>
</div>
<div className="max-h-[400px] overflow-auto rounded border">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted">
<tr className="border-b">
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
</th>
<th className="px-3 py-2 text-center font-medium text-muted-foreground">
</th>
<th className="w-16 px-3 py-2 text-center font-medium text-muted-foreground">
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="py-8 text-center text-muted-foreground">
...
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-muted-foreground">
</td>
</tr>
) : (
data.map((item, idx) => (
<tr
key={item.id || idx}
className="border-b transition-colors hover:bg-muted/30"
>
<td className="px-3 py-2">{item.inspection_code || "-"}</td>
<td className="px-3 py-2">
{item.inspection_item || item.inspection_criteria || "-"}
</td>
<td className="px-3 py-2">{item.inspection_method || "-"}</td>
<td className="px-3 py-2 text-center">
{item.lower_limit || "-"}
</td>
<td className="px-3 py-2 text-center">
{item.upper_limit || "-"}
</td>
<td className="px-3 py-2 text-center">{item.unit || "-"}</td>
<td className="px-3 py-2 text-center">
<Button
size="sm"
className="h-7 px-3 text-xs"
onClick={() => handleSelect(item)}
>
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -87,7 +87,7 @@ export function ItemProcessSelector({
</div> </div>
) : ( ) : (
items.map((item) => ( items.map((item) => (
<div key={item.item_code} className="mb-1"> <div key={item.id} className="mb-1">
{/* 품목 헤더 */} {/* 품목 헤더 */}
<button <button
onClick={() => toggleItem(item.item_code, item.item_name)} onClick={() => toggleItem(item.item_code, item.item_name)}

View File

@ -1,19 +1,12 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { Plus, Pencil, Trash2, Check, X, HandMetal } from "lucide-react"; import { Plus, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types"; import { WorkItem, WorkItemDetail, DetailTypeDefinition } from "../types";
import { DetailFormModal } from "./DetailFormModal";
interface WorkItemDetailListProps { interface WorkItemDetailListProps {
workItem: WorkItem | null; workItem: WorkItem | null;
@ -34,20 +27,13 @@ export function WorkItemDetailList({
onUpdateDetail, onUpdateDetail,
onDeleteDetail, onDeleteDetail,
}: WorkItemDetailListProps) { }: WorkItemDetailListProps) {
const [editingId, setEditingId] = useState<string | null>(null); const [modalOpen, setModalOpen] = useState(false);
const [editData, setEditData] = useState<Partial<WorkItemDetail>>({}); const [modalMode, setModalMode] = useState<"add" | "edit">("add");
const [isAdding, setIsAdding] = useState(false); const [editTarget, setEditTarget] = useState<WorkItemDetail | null>(null);
const [newData, setNewData] = useState<Partial<WorkItemDetail>>({
detail_type: detailTypes[0]?.value || "",
content: "",
is_required: "N",
sort_order: 0,
});
if (!workItem) { if (!workItem) {
return ( return (
<div className="flex h-full flex-col items-center justify-center text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
<HandMetal className="mb-2 h-10 w-10 text-amber-400" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
</p> </p>
@ -58,25 +44,60 @@ export function WorkItemDetailList({
const getTypeLabel = (value?: string) => const getTypeLabel = (value?: string) =>
detailTypes.find((t) => t.value === value)?.label || value || "-"; detailTypes.find((t) => t.value === value)?.label || value || "-";
const handleAdd = () => { const handleOpenAdd = () => {
if (!newData.content?.trim()) return; setModalMode("add");
onCreateDetail({ setEditTarget(null);
...newData, setModalOpen(true);
sort_order: details.length + 1,
});
setNewData({
detail_type: detailTypes[0]?.value || "",
content: "",
is_required: "N",
sort_order: 0,
});
setIsAdding(false);
}; };
const handleSaveEdit = (id: string) => { const handleOpenEdit = (detail: WorkItemDetail) => {
onUpdateDetail(id, editData); setModalMode("edit");
setEditingId(null); setEditTarget(detail);
setEditData({}); setModalOpen(true);
};
const handleSubmit = (data: Partial<WorkItemDetail>) => {
if (modalMode === "add") {
onCreateDetail({ ...data, sort_order: details.length + 1 });
} else if (editTarget) {
onUpdateDetail(editTarget.id, data);
}
};
const getContentSummary = (detail: WorkItemDetail): string => {
const type = detail.detail_type;
if (type === "inspect" && detail.inspection_code) {
const parts = [detail.content];
if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`);
if (detail.lower_limit || detail.upper_limit) {
parts.push(`(${detail.lower_limit || "-"} ~ ${detail.upper_limit || "-"} ${detail.unit || ""})`);
}
return parts.join(" ");
}
if (type === "procedure" && detail.duration_minutes) {
return `${detail.content} (${detail.duration_minutes}분)`;
}
if (type === "input" && detail.input_type) {
const typeMap: Record<string, string> = {
text: "텍스트",
number: "숫자",
date: "날짜",
textarea: "장문",
select: "선택형",
};
return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`;
}
if (type === "info" && detail.lookup_target) {
const targetMap: Record<string, string> = {
equipment: "설비정보",
material: "자재정보",
worker: "작업자정보",
tool: "공구정보",
document: "문서정보",
};
return `${targetMap[detail.lookup_target] || detail.lookup_target} 조회`;
}
return detail.content || "-";
}; };
return ( return (
@ -94,7 +115,7 @@ export function WorkItemDetailList({
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 gap-1 text-xs" className="h-7 gap-1 text-xs"
onClick={() => setIsAdding(true)} onClick={handleOpenAdd}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
@ -132,242 +153,51 @@ export function WorkItemDetailList({
key={detail.id} key={detail.id}
className="border-b transition-colors hover:bg-muted/30" className="border-b transition-colors hover:bg-muted/30"
> >
{editingId === detail.id ? ( <td className="px-2 py-1.5 text-center text-muted-foreground">
<> {idx + 1}
<td className="px-2 py-1.5 text-center">{idx + 1}</td> </td>
<td className="px-2 py-1.5"> <td className="px-2 py-1.5">
<Select <Badge variant="outline" className="text-[10px] font-normal">
value={editData.detail_type || detail.detail_type || ""} {getTypeLabel(detail.detail_type)}
onValueChange={(v) => </Badge>
setEditData((prev) => ({ </td>
...prev, <td className="px-2 py-1.5">{getContentSummary(detail)}</td>
detail_type: v, <td className="px-2 py-1.5 text-center">
})) <Badge
} variant={detail.is_required === "Y" ? "default" : "secondary"}
className="text-[10px] font-normal"
>
{detail.is_required === "Y" ? "필수" : "선택"}
</Badge>
</td>
{!readonly && (
<td className="px-2 py-1.5 text-center">
<div className="flex justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleOpenEdit(detail)}
> >
<SelectTrigger className="h-7 text-xs"> <Pencil className="h-3 w-3" />
<SelectValue /> </Button>
</SelectTrigger> <Button
<SelectContent> variant="ghost"
{detailTypes.map((t) => ( size="icon"
<SelectItem className="h-6 w-6 text-destructive hover:text-destructive"
key={t.value} onClick={() => onDeleteDetail(detail.id)}
value={t.value}
className="text-xs"
>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5">
<Input
value={editData.content ?? detail.content}
onChange={(e) =>
setEditData((prev) => ({
...prev,
content: e.target.value,
}))
}
className="h-7 text-xs"
/>
</td>
<td className="px-2 py-1.5 text-center">
<Select
value={editData.is_required ?? detail.is_required}
onValueChange={(v) =>
setEditData((prev) => ({
...prev,
is_required: v,
}))
}
> >
<SelectTrigger className="h-7 w-14 text-xs"> <Trash2 className="h-3 w-3" />
<SelectValue /> </Button>
</SelectTrigger> </div>
<SelectContent> </td>
<SelectItem value="Y" className="text-xs">
</SelectItem>
<SelectItem value="N" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5 text-center">
<div className="flex justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-green-600"
onClick={() => handleSaveEdit(detail.id)}
>
<Check className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setEditingId(null);
setEditData({});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
</td>
</>
) : (
<>
<td className="px-2 py-1.5 text-center text-muted-foreground">
{idx + 1}
</td>
<td className="px-2 py-1.5">
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{getTypeLabel(detail.detail_type)}
</Badge>
</td>
<td className="px-2 py-1.5">{detail.content}</td>
<td className="px-2 py-1.5 text-center">
<Badge
variant={
detail.is_required === "Y" ? "default" : "secondary"
}
className="text-[10px] font-normal"
>
{detail.is_required === "Y" ? "필수" : "선택"}
</Badge>
</td>
{!readonly && (
<td className="px-2 py-1.5 text-center">
<div className="flex justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
setEditingId(detail.id);
setEditData({
detail_type: detail.detail_type,
content: detail.content,
is_required: detail.is_required,
});
}}
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => onDeleteDetail(detail.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</td>
)}
</>
)} )}
</tr> </tr>
))} ))}
{/* 추가 행 */}
{isAdding && (
<tr className="border-b bg-primary/5">
<td className="px-2 py-1.5 text-center text-muted-foreground">
{details.length + 1}
</td>
<td className="px-2 py-1.5">
<Select
value={newData.detail_type || ""}
onValueChange={(v) =>
setNewData((prev) => ({ ...prev, detail_type: v }))
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{detailTypes.map((t) => (
<SelectItem
key={t.value}
value={t.value}
className="text-xs"
>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5">
<Input
autoFocus
placeholder="상세 내용 입력"
value={newData.content || ""}
onChange={(e) =>
setNewData((prev) => ({
...prev,
content: e.target.value,
}))
}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
className="h-7 text-xs"
/>
</td>
<td className="px-2 py-1.5 text-center">
<Select
value={newData.is_required || "N"}
onValueChange={(v) =>
setNewData((prev) => ({ ...prev, is_required: v }))
}
>
<SelectTrigger className="h-7 w-14 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs">
</SelectItem>
<SelectItem value="N" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-2 py-1.5 text-center">
<div className="flex justify-center gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-green-600"
onClick={handleAdd}
>
<Check className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setIsAdding(false)}
>
<X className="h-3 w-3" />
</Button>
</div>
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
{details.length === 0 && !isAdding && ( {details.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
. &quot; &quot; . . &quot; &quot; .
@ -375,6 +205,16 @@ export function WorkItemDetailList({
</div> </div>
)} )}
</div> </div>
{/* 추가/수정 모달 */}
<DetailFormModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSubmit={handleSubmit}
detailTypes={detailTypes}
editData={editTarget}
mode={modalMode}
/>
</div> </div>
); );
} }

View File

@ -23,9 +23,11 @@ export const defaultConfig: ProcessWorkStandardConfig = {
{ key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 }, { key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
], ],
detailTypes: [ detailTypes: [
{ value: "CHECK", label: "체크" }, { value: "check", label: "체크리스트" },
{ value: "INSPECTION", label: "검사" }, { value: "inspect", label: "검사항목" },
{ value: "MEASUREMENT", label: "측정" }, { value: "procedure", label: "작업절차" },
{ value: "input", label: "직접입력" },
{ value: "info", label: "정보조회" },
], ],
splitRatio: 30, splitRatio: 30,
leftPanelTitle: "품목 및 공정 선택", leftPanelTitle: "품목 및 공정 선택",

View File

@ -11,7 +11,7 @@ import {
SelectionState, SelectionState,
} from "../types"; } from "../types";
const API_BASE = "/api/process-work-standard"; const API_BASE = "/process-work-standard";
export function useProcessWorkStandard(config: ProcessWorkStandardConfig) { export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
const [items, setItems] = useState<ItemData[]>([]); const [items, setItems] = useState<ItemData[]>([]);

View File

@ -87,6 +87,29 @@ export interface WorkItemDetail {
sort_order: number; sort_order: number;
remark?: string; remark?: string;
created_date?: string; created_date?: string;
// 검사항목 전용
inspection_code?: string;
inspection_method?: string;
unit?: string;
lower_limit?: string;
upper_limit?: string;
// 작업절차 전용
duration_minutes?: number;
// 직접입력 전용
input_type?: string;
// 정보조회 전용
lookup_target?: string;
display_fields?: string;
}
export interface InspectionStandard {
id: string;
inspection_code: string;
inspection_item: string;
inspection_method: string;
unit: string;
lower_limit?: string;
upper_limit?: string;
} }
// ============================================================ // ============================================================

View File

@ -97,6 +97,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
return ( return (
<V2Repeater <V2Repeater
config={config} config={config}
componentId={component?.id}
parentId={resolvedParentId} parentId={resolvedParentId}
data={Array.isArray(data) ? data : undefined} data={Array.isArray(data) ? data : undefined}
onDataChange={onDataChange} onDataChange={onDataChange}

View File

@ -13,7 +13,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2SelectDefinition; static componentDefinition = V2SelectDefinition;
render(): React.ReactElement { render(): React.ReactElement {
const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, allComponents, ...restProps } = this.props as any;
// 컴포넌트 설정 추출 // 컴포넌트 설정 추출
const config = component.componentConfig || component.config || {}; const config = component.componentConfig || component.config || {};
@ -107,8 +107,7 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
// 디버깅 필요시 주석 해제 // 디버깅 필요시 주석 해제
// console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize }); // console.log("🔍 [V2SelectRenderer]", { componentId: component.id, effectiveStyle, effectiveSize });
// 🔧 restProps에서 style, size 제외 (effectiveStyle/effectiveSize가 우선되어야 함) const { style: _style, size: _size, allComponents: _allComp, ...restPropsClean } = restProps as any;
const { style: _style, size: _size, ...restPropsClean } = restProps as any;
return ( return (
<V2Select <V2Select
@ -119,9 +118,10 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
disabled={config.disabled || component.disabled} disabled={config.disabled || component.disabled}
value={currentValue} value={currentValue}
onChange={handleChange} onChange={handleChange}
onFormDataChange={isInteractive ? onFormDataChange : undefined}
allComponents={allComponents}
config={{ config={{
mode: config.mode || "dropdown", mode: config.mode || "dropdown",
// 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선)
source: isCategoryType ? "category" : (config.source || "distinct"), source: isCategoryType ? "category" : (config.source || "distinct"),
multiple: config.multiple || false, multiple: config.multiple || false,
searchable: config.searchable ?? true, searchable: config.searchable ?? true,
@ -131,7 +131,6 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
entityTable: config.entityTable, entityTable: config.entityTable,
entityLabelColumn: config.entityLabelColumn, entityLabelColumn: config.entityLabelColumn,
entityValueColumn: config.entityValueColumn, entityValueColumn: config.entityValueColumn,
// 🔧 카테고리 소스 지원 (tableName, columnName 폴백)
categoryTable: config.categoryTable || (isCategoryType ? tableName : undefined), categoryTable: config.categoryTable || (isCategoryType ? tableName : undefined),
categoryColumn: config.categoryColumn || (isCategoryType ? columnName : undefined), categoryColumn: config.categoryColumn || (isCategoryType ? columnName : undefined),
}} }}

View File

@ -25,7 +25,8 @@ import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client"; import { apiClient, getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -51,6 +52,42 @@ export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
selectedPanelComponentId?: string; selectedPanelComponentId?: string;
} }
// 이미지 셀 렌더링 컴포넌트 (objid 또는 파일 경로 지원)
const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
React.useEffect(() => {
if (!value) return;
const strVal = String(value).trim();
if (!strVal || strVal === "-") return;
if (strVal.startsWith("http") || strVal.startsWith("/uploads/") || strVal.startsWith("/api/")) {
setImgSrc(getFullImageUrl(strVal));
} else {
const previewUrl = getFilePreviewUrl(strVal);
fetch(previewUrl, { credentials: "include" })
.then((res) => {
if (!res.ok) throw new Error("fetch failed");
return res.blob();
})
.then((blob) => setImgSrc(URL.createObjectURL(blob)))
.catch(() => setImgSrc(null));
}
}, [value]);
if (!imgSrc) return <span className="text-muted-foreground text-xs">-</span>;
return (
<img
src={imgSrc}
alt=""
className="h-8 w-8 rounded object-cover"
onError={() => setImgSrc(null)}
/>
);
});
SplitPanelCellImage.displayName = "SplitPanelCellImage";
/** /**
* SplitPanelLayout * SplitPanelLayout
* - * -
@ -210,6 +247,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보 const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들 const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
// 추가 탭 관련 상태 // 추가 탭 관련 상태
@ -905,6 +943,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
) => { ) => {
if (value === null || value === undefined) return "-"; if (value === null || value === undefined) return "-";
// 이미지 타입 컬럼 처리
const colInputType = columnInputTypes[columnName];
if (colInputType === "image" && value) {
return <SplitPanelCellImage value={String(value)} />;
}
// 🆕 날짜 포맷 적용 // 🆕 날짜 포맷 적용
if (format?.type === "date" || format?.dateFormat) { if (format?.type === "date" || format?.dateFormat) {
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
@ -971,7 +1015,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 일반 값 // 일반 값
return String(value); return String(value);
}, },
[formatDateValue, formatNumberValue], [formatDateValue, formatNumberValue, columnInputTypes],
); );
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼 // 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
@ -1835,14 +1879,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
}); });
setRightColumnLabels(labels); setRightColumnLabels(labels);
console.log("✅ 우측 컬럼 라벨 로드:", labels);
// 컬럼 inputType 로드 (이미지 등 특수 렌더링을 위해)
const tablesToLoad = new Set<string>([rightTableName]);
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
additionalTabs.forEach((tab: any) => {
if (tab.tableName) tablesToLoad.add(tab.tableName);
});
const inputTypes: Record<string, string> = {};
for (const tbl of tablesToLoad) {
try {
const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl);
inputTypesResponse.forEach((col: any) => {
const colName = col.columnName || col.column_name;
if (colName) {
inputTypes[colName] = col.inputType || "text";
}
});
} catch {
// ignore
}
}
setColumnInputTypes(inputTypes);
} catch (error) { } catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error); console.error("우측 테이블 컬럼 정보 로드 실패:", error);
} }
}; };
loadRightTableColumns(); loadRightTableColumns();
}, [componentConfig.rightPanel?.tableName, isDesignMode]); }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
// 좌측 테이블 카테고리 매핑 로드 // 좌측 테이블 카테고리 매핑 로드
useEffect(() => { useEffect(() => {

View File

@ -21,7 +21,9 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
React.useEffect(() => { React.useEffect(() => {
let mounted = true; let mounted = true;
const strValue = String(value); // 다중 이미지인 경우 대표 이미지(첫 번째)만 사용
const rawValue = String(value);
const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue); const isObjid = /^\d+$/.test(strValue);
if (isObjid) { if (isObjid) {
@ -89,8 +91,8 @@ const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
style={{ maxWidth: "40px", maxHeight: "40px" }} style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// objid인 경우 preview URL로 열기, 아니면 full URL로 열기 const rawValue = String(value);
const strValue = String(value); const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue;
const isObjid = /^\d+$/.test(strValue); const isObjid = /^\d+$/.test(strValue);
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue); const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
window.open(openUrl, "_blank"); window.open(openUrl, "_blank");
@ -652,7 +654,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20); const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]);
const [columnMeta, setColumnMeta] = useState< const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string }> Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }>
>({}); >({});
// 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType) // 🆕 엔티티 조인 테이블의 컬럼 메타데이터 (테이블명.컬럼명 → inputType)
const [joinedColumnMeta, setJoinedColumnMeta] = useState< const [joinedColumnMeta, setJoinedColumnMeta] = useState<
@ -863,6 +865,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const dataProvider: DataProvidable = { const dataProvider: DataProvidable = {
componentId: component.id, componentId: component.id,
componentType: "table-list", componentType: "table-list",
tableName: tableConfig.selectedTable,
getSelectedData: () => { getSelectedData: () => {
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
@ -1015,23 +1018,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
} }
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 // 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환)
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`);
if (response.data.success && response.data.data && response.data.data.length > 0) {
return response.data.data.map((item: any) => ({
value: String(item.value),
label: String(item.label),
}));
}
} catch (error: any) {
// DISTINCT API 실패 시 현재 데이터 기반으로 fallback
}
// fallback: 현재 로드된 데이터에서 고유 값 추출
const isLabelType = ["category", "entity", "code"].includes(inputType); const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName; const labelField = isLabelType ? `${columnName}_name` : columnName;
// 현재 로드된 데이터에서 고유 값 추출 const uniqueValuesMap = new Map<string, string>();
const uniqueValuesMap = new Map<string, string>(); // value -> label
data.forEach((row) => { data.forEach((row) => {
const value = row[columnName]; const value = row[columnName];
if (value !== null && value !== undefined && value !== "") { if (value !== null && value !== undefined && value !== "") {
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
const label = isLabelType && row[labelField] ? row[labelField] : String(value); const label = isLabelType && row[labelField] ? row[labelField] : String(value);
uniqueValuesMap.set(String(value), label); uniqueValuesMap.set(String(value), label);
} }
}); });
// Map을 배열로 변환하고 라벨 기준으로 정렬
const result = Array.from(uniqueValuesMap.entries()) const result = Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({ .map(([value, label]) => ({
value: value, value: value,
@ -1219,13 +1234,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const cached = tableColumnCache.get(cacheKey); const cached = tableColumnCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) { if (cached && Date.now() - cached.timestamp < TABLE_CACHE_TTL) {
const labels: Record<string, string> = {}; const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {}; const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
// 캐시된 inputTypes 맵 생성
const inputTypeMap: Record<string, string> = {}; const inputTypeMap: Record<string, string> = {};
const categoryRefMap: Record<string, string> = {};
if (cached.inputTypes) { if (cached.inputTypes) {
cached.inputTypes.forEach((col: any) => { cached.inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType; inputTypeMap[col.columnName] = col.inputType;
if (col.categoryRef) {
categoryRefMap[col.columnName] = col.categoryRef;
}
}); });
} }
@ -1234,7 +1252,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
meta[col.columnName] = { meta[col.columnName] = {
webType: col.webType, webType: col.webType,
codeCategory: col.codeCategory, codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName], // 캐시된 inputType 사용! inputType: inputTypeMap[col.columnName],
categoryRef: categoryRefMap[col.columnName],
}; };
}); });
@ -1245,11 +1264,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const columns = await tableTypeApi.getColumns(tableConfig.selectedTable); const columns = await tableTypeApi.getColumns(tableConfig.selectedTable);
// 컬럼 입력 타입 정보 가져오기
const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable); const inputTypes = await tableTypeApi.getColumnInputTypes(tableConfig.selectedTable);
const inputTypeMap: Record<string, string> = {}; const inputTypeMap: Record<string, string> = {};
const categoryRefMap: Record<string, string> = {};
inputTypes.forEach((col: any) => { inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType; inputTypeMap[col.columnName] = col.inputType;
if (col.categoryRef) {
categoryRefMap[col.columnName] = col.categoryRef;
}
}); });
tableColumnCache.set(cacheKey, { tableColumnCache.set(cacheKey, {
@ -1259,7 +1281,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
const labels: Record<string, string> = {}; const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string }> = {}; const meta: Record<string, { webType?: string; codeCategory?: string; inputType?: string; categoryRef?: string }> = {};
columns.forEach((col: any) => { columns.forEach((col: any) => {
labels[col.columnName] = col.displayName || col.comment || col.columnName; labels[col.columnName] = col.displayName || col.comment || col.columnName;
@ -1267,6 +1289,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
webType: col.webType, webType: col.webType,
codeCategory: col.codeCategory, codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName], inputType: inputTypeMap[col.columnName],
categoryRef: categoryRefMap[col.columnName],
}; };
}); });
@ -1341,14 +1364,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
for (const columnName of categoryColumns) { for (const columnName of categoryColumns) {
try { try {
// 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인
let targetTable = tableConfig.selectedTable; let targetTable = tableConfig.selectedTable;
let targetColumn = columnName; let targetColumn = columnName;
if (columnName.includes(".")) { // category_ref가 있으면 참조 테이블.컬럼 기준으로 조회
const meta = columnMeta[columnName];
if (meta?.categoryRef) {
const refParts = meta.categoryRef.split(".");
if (refParts.length === 2) {
targetTable = refParts[0];
targetColumn = refParts[1];
}
} else if (columnName.includes(".")) {
// 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태
const parts = columnName.split("."); const parts = columnName.split(".");
targetTable = parts[0]; // 조인된 테이블명 (예: item_info) targetTable = parts[0];
targetColumn = parts[1]; // 실제 컬럼명 (예: material) targetColumn = parts[1];
} }
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
@ -1549,7 +1580,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
categoryColumns.length, categoryColumns.length,
JSON.stringify(categoryColumns), JSON.stringify(categoryColumns),
JSON.stringify(tableConfig.columns), JSON.stringify(tableConfig.columns),
]); // 더 명확한 의존성 columnMeta,
]);
// ======================================== // ========================================
// 데이터 가져오기 // 데이터 가져오기
@ -4255,7 +4287,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 다중 값인 경우: 여러 배지 렌더링 // 다중 값인 경우: 여러 배지 렌더링
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-nowrap gap-1 overflow-hidden">
{values.map((val, idx) => { {values.map((val, idx) => {
const categoryData = mapping?.[val]; const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val; const displayLabel = categoryData?.label || val;
@ -4264,7 +4296,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) { if (!displayColor || displayColor === "none" || !categoryData) {
return ( return (
<span key={idx} className="text-sm"> <span key={idx} className="shrink-0 whitespace-nowrap text-sm">
{displayLabel} {displayLabel}
{idx < values.length - 1 && ", "} {idx < values.length - 1 && ", "}
</span> </span>
@ -4278,7 +4310,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
backgroundColor: displayColor, backgroundColor: displayColor,
borderColor: displayColor, borderColor: displayColor,
}} }}
className="text-white" className="shrink-0 whitespace-nowrap text-white"
> >
{displayLabel} {displayLabel}
</Badge> </Badge>

View File

@ -20,8 +20,8 @@ function normalizeFormDataArrays(formData: Record<string, any>): Record<string,
if (Array.isArray(value)) { if (Array.isArray(value)) {
// 배열 내 숫자를 문자열로 변환 후 쉼표 구분 // 배열 내 숫자를 문자열로 변환 후 쉼표 구분
const stringValue = value const stringValue = value
.map(v => typeof v === "number" ? String(v) : v) .map((v) => (typeof v === "number" ? String(v) : v))
.filter(v => v !== null && v !== undefined && v !== "") .filter((v) => v !== null && v !== undefined && v !== "")
.join(","); .join(",");
console.log(`🔧 [normalizeFormDataArrays] 배열→문자열: ${key}`, { original: value, converted: stringValue }); console.log(`🔧 [normalizeFormDataArrays] 배열→문자열: ${key}`, { original: value, converted: stringValue });
normalized[key] = stringValue; normalized[key] = stringValue;
@ -559,6 +559,7 @@ export class ButtonActionExecutor {
} }
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave) { if (onSave) {
try { try {
await onSave(); await onSave();
@ -587,6 +588,7 @@ export class ButtonActionExecutor {
skipDefaultSave: false, skipDefaultSave: false,
validationFailed: false, validationFailed: false,
validationErrors: [] as string[], validationErrors: [] as string[],
pendingPromises: [] as Promise<void>[],
}; };
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("beforeFormSave", { new CustomEvent("beforeFormSave", {
@ -594,15 +596,15 @@ export class ButtonActionExecutor {
}), }),
); );
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 // 비동기 핸들러가 등록한 Promise들 대기 + 동기 핸들러를 위한 최소 대기
await new Promise((resolve) => setTimeout(resolve, 100)); if (beforeSaveEventDetail.pendingPromises.length > 0) {
console.log(
// 🔧 디버그: beforeFormSave 이벤트 후 formData 확인 `[handleSave] 비동기 beforeFormSave 핸들러 ${beforeSaveEventDetail.pendingPromises.length}건 대기 중...`,
console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", { );
keys: Object.keys(context.formData || {}), await Promise.all(beforeSaveEventDetail.pendingPromises);
hasCompanyImage: "company_image" in (context.formData || {}), } else {
companyImageValue: context.formData?.company_image, await new Promise((resolve) => setTimeout(resolve, 100));
}); }
// 검증 실패 시 저장 중단 // 검증 실패 시 저장 중단
if (beforeSaveEventDetail.validationFailed) { if (beforeSaveEventDetail.validationFailed) {
@ -610,22 +612,9 @@ export class ButtonActionExecutor {
return false; return false;
} }
// 🔧 skipDefaultSave 플래그 확인 - SelectedItemsDetailInput 등에서 자체 UPSERT 처리 시 기본 저장 건너뛰기
if (beforeSaveEventDetail.skipDefaultSave) {
return true;
}
// 🆕 _tableSection_ 데이터가 있는지 확인 (TableSectionRenderer 사용 시)
// beforeFormSave 이벤트 후에 체크해야 UniversalFormModal에서 병합된 데이터를 확인할 수 있음
const hasTableSectionData = Object.keys(context.formData || {}).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
);
if (hasTableSectionData) {
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리) // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
// EditModal이 내부에서 직접 repeaterSave 이벤트를 발행하고 완료를 기다림
if (onSave && !hasTableSectionData) { if (onSave && !hasTableSectionData) {
try { try {
await onSave(); await onSave();
@ -636,6 +625,25 @@ export class ButtonActionExecutor {
} }
} }
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
// 🔧 디버그: beforeFormSave 이벤트 후 formData 확인
console.log("🔍 [handleSave] beforeFormSave 이벤트 후:", {
keys: Object.keys(context.formData || {}),
hasCompanyImage: "company_image" in (context.formData || {}),
companyImageValue: context.formData?.company_image,
});
// skipDefaultSave 플래그 확인
if (beforeSaveEventDetail.skipDefaultSave) {
return true;
}
// _tableSection_ 데이터 확인 (TableSectionRenderer 사용 시)
const hasTableSectionData = Object.keys(context.formData || {}).some(
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
);
console.log("⚠️ [handleSave] 기본 저장 로직 실행 (onSave 콜백 없음 또는 _tableSection_ 데이터 있음)"); console.log("⚠️ [handleSave] 기본 저장 로직 실행 (onSave 콜백 없음 또는 _tableSection_ 데이터 있음)");
// 🆕 렉 구조 컴포넌트 일괄 저장 감지 // 🆕 렉 구조 컴포넌트 일괄 저장 감지
@ -1036,7 +1044,8 @@ export class ButtonActionExecutor {
const value = formData[key]; const value = formData[key];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터) // 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 && const isRepeaterData =
value.length > 0 &&
typeof value[0] === "object" && typeof value[0] === "object" &&
value[0] !== null && value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]); ("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
@ -1044,10 +1053,13 @@ export class ButtonActionExecutor {
if (!isRepeaterData) { if (!isRepeaterData) {
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 // 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환
const stringValue = value const stringValue = value
.map(v => typeof v === "number" ? String(v) : v) .map((v) => (typeof v === "number" ? String(v) : v))
.filter(v => v !== null && v !== undefined && v !== "") .filter((v) => v !== null && v !== undefined && v !== "")
.join(","); .join(",");
console.log(`🔧 [handleSave UPDATE] 배열→문자열 변환: ${key}`, { original: value, converted: stringValue }); console.log(`🔧 [handleSave UPDATE] 배열→문자열 변환: ${key}`, {
original: value,
converted: stringValue,
});
formData[key] = stringValue; formData[key] = stringValue;
} }
} }
@ -1213,7 +1225,8 @@ export class ButtonActionExecutor {
const value = dataWithUserInfo[key]; const value = dataWithUserInfo[key];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터) // 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 && const isRepeaterData =
value.length > 0 &&
typeof value[0] === "object" && typeof value[0] === "object" &&
value[0] !== null && value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]); ("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
@ -1224,8 +1237,8 @@ export class ButtonActionExecutor {
} else { } else {
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 // 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환
const stringValue = value const stringValue = value
.map(v => typeof v === "number" ? String(v) : v) .map((v) => (typeof v === "number" ? String(v) : v))
.filter(v => v !== null && v !== undefined && v !== "") .filter((v) => v !== null && v !== undefined && v !== "")
.join(","); .join(",");
console.log(`🔧 [handleSave] 배열→문자열 변환: ${key}`, { original: value, converted: stringValue }); console.log(`🔧 [handleSave] 배열→문자열 변환: ${key}`, { original: value, converted: stringValue });
dataWithUserInfo[key] = stringValue; dataWithUserInfo[key] = stringValue;
@ -1300,6 +1313,9 @@ export class ButtonActionExecutor {
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리) // _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue; if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
// _deferSave 플래그가 있으면 메인 저장 후 처리 (마스터-디테일 순차 저장)
if (firstItem?._deferSave) continue;
// 🆕 V2Repeater가 등록된 테이블이면 RepeaterFieldGroup 저장 스킵 // 🆕 V2Repeater가 등록된 테이블이면 RepeaterFieldGroup 저장 스킵
// V2Repeater가 repeaterSave 이벤트로 저장 처리함 // V2Repeater가 repeaterSave 이벤트로 저장 처리함
// @ts-ignore - window에 동적 속성 사용 // @ts-ignore - window에 동적 속성 사용
@ -1390,6 +1406,7 @@ export class ButtonActionExecutor {
_existingRecord: __, _existingRecord: __,
_originalItemIds: ___, _originalItemIds: ___,
_deletedItemIds: ____, _deletedItemIds: ____,
_fkColumn: itemFkColumn,
...dataToSave ...dataToSave
} = item; } = item;
@ -1398,12 +1415,18 @@ export class ButtonActionExecutor {
delete dataToSave.id; delete dataToSave.id;
} }
// BOM 에디터 등 마스터-디테일: FK 값이 없으면 메인 저장 결과의 ID 주입
if (itemFkColumn && (!dataToSave[itemFkColumn] || dataToSave[itemFkColumn] === null)) {
const mainSavedId = saveResult?.data?.id || saveResult?.data?.data?.id || context.formData?.id;
if (mainSavedId) {
dataToSave[itemFkColumn] = mainSavedId;
}
}
// 🆕 공통 필드 병합 + 사용자 정보 추가 // 🆕 공통 필드 병합 + 사용자 정보 추가
// 개별 항목 데이터를 먼저 넣고, 공통 필드로 덮어씀 (공통 필드가 우선)
// 이유: 사용자가 공통 필드(출고상태 등)를 변경하면 모든 항목에 적용되어야 함
const dataWithMeta: Record<string, unknown> = { const dataWithMeta: Record<string, unknown> = {
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터 ...dataToSave,
...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선! ...commonFields,
created_by: context.userId, created_by: context.userId,
updated_by: context.userId, updated_by: context.userId,
company_code: context.companyCode, company_code: context.companyCode,
@ -1484,13 +1507,25 @@ export class ButtonActionExecutor {
// @ts-ignore - window에 동적 속성 사용 // @ts-ignore - window에 동적 속성 사용
const v2RepeaterTables = Array.from(window.__v2RepeaterInstances || []); const v2RepeaterTables = Array.from(window.__v2RepeaterInstances || []);
// V2Repeater가 동일 테이블에 존재하는지 allComponents로 감지
// (useCustomTable 미설정 = 화면 테이블에 직접 저장하는 리피터)
const hasRepeaterOnSameTable =
context.allComponents?.some((c: any) => {
const compType = c.componentType || c.overrides?.type;
if (compType !== "v2-repeater") return false;
const compConfig = c.componentConfig || c.overrides || {};
return !compConfig.useCustomTable;
}) || false;
// 메인 저장 건너뛰기 조건: // 메인 저장 건너뛰기 조건:
// 1. RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리 // 1. RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
// 2. V2Repeater가 같은 테이블에 존재 (리피터 데이터에 메인 폼 데이터 병합되어 저장됨) // 2. V2Repeater가 같은 테이블에 존재 (리피터 데이터에 메인 폼 데이터 병합되어 저장됨)
// 3. allComponents에서 useCustomTable 미설정 V2Repeater 감지 (글로벌 등록 없는 경우)
const shouldSkipMainSave = const shouldSkipMainSave =
repeatScreenModalTables.includes(tableName) || repeatScreenModalTables.includes(tableName) ||
repeaterFieldGroupTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName) ||
v2RepeaterTables.includes(tableName); v2RepeaterTables.includes(tableName) ||
hasRepeaterOnSameTable;
if (shouldSkipMainSave) { if (shouldSkipMainSave) {
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/V2Repeater에서 처리" }; saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal/V2Repeater에서 처리" };
@ -1769,18 +1804,72 @@ export class ButtonActionExecutor {
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
} }
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행) // V2Repeater 저장 이벤트 발생 (모달 닫기 전에 실행해야 V2Repeater가 이벤트를 수신할 수 있음)
context.onRefresh?.();
context.onFlowRefresh?.();
// 저장 성공 후 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리
// V2Repeater 저장 이벤트 발생 (메인 폼 데이터 + 리피터 데이터 병합 저장)
// 🔧 formData를 리피터에 전달하여 각 행에 병합 저장
const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id; const savedId = saveResult?.data?.id || saveResult?.data?.data?.id || formData.id || context.formData?.id;
// _deferSave 데이터 처리 (마스터-디테일 순차 저장: 레벨별 저장 + temp→real ID 매핑)
if (savedId) {
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
let parsedData = fieldValue;
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
try {
parsedData = JSON.parse(fieldValue);
} catch {
continue;
}
}
if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
if (!parsedData[0]?._deferSave) continue;
const targetTable = parsedData[0]?._targetTable;
if (!targetTable) continue;
// 레벨별 그룹핑 (레벨 0 먼저 저장 → 레벨 1 → ...)
const maxLevel = Math.max(...parsedData.map((item: any) => Number(item.level) || 0));
const tempIdToRealId = new Map<string, string>();
for (let lvl = 0; lvl <= maxLevel; lvl++) {
const levelItems = parsedData.filter((item: any) => (Number(item.level) || 0) === lvl);
for (const item of levelItems) {
const { _targetTable: _, _isNew, _deferSave: __, _fkColumn: fkCol, tempId, ...data } = item;
if (!data.id || data.id === "") delete data.id;
// FK 주입 (bom_id 등)
if (fkCol) data[fkCol] = savedId;
// parent_detail_id의 temp 참조를 실제 ID로 교체
if (data.parent_detail_id && tempIdToRealId.has(data.parent_detail_id)) {
data.parent_detail_id = tempIdToRealId.get(data.parent_detail_id);
}
// 시스템 필드 추가
data.created_by = context.userId;
data.updated_by = context.userId;
data.company_code = context.companyCode;
try {
const isNew = _isNew || !item.id || item.id === "";
if (isNew) {
const res = await apiClient.post(`/table-management/tables/${targetTable}/add`, data);
const newId = res.data?.data?.id || res.data?.id;
if (newId && tempId) {
tempIdToRealId.set(tempId, newId);
}
} else {
await apiClient.put(`/table-management/tables/${targetTable}/${item.id}`, data);
if (item.id && tempId) {
tempIdToRealId.set(tempId, item.id);
}
}
} catch (err: any) {
console.error(`[handleSave] 디테일 저장 실패 (${targetTable}):`, err.response?.data || err.message);
}
}
}
}
}
// 메인 폼 데이터 구성 (사용자 정보 포함) // 메인 폼 데이터 구성 (사용자 정보 포함)
const mainFormData = { const mainFormData = {
...formData, ...formData,
@ -1797,17 +1886,45 @@ export class ButtonActionExecutor {
} }
} }
console.log("🟢 [buttonActions] repeaterSave 이벤트 발행:", {
parentId: savedId,
tableName: context.tableName,
masterRecordId: savedId,
mainFormDataKeys: Object.keys(mainFormData),
});
// V2Repeater 저장 완료를 기다리기 위한 Promise
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("repeaterSave", { new CustomEvent("repeaterSave", {
detail: { detail: {
parentId: savedId, parentId: savedId,
tableName: context.tableName, tableName: context.tableName,
mainFormData, // 🆕 메인 폼 데이터 전달 mainFormData,
masterRecordId: savedId, // 🆕 마스터 레코드 ID (FK 자동 연결용) masterRecordId: savedId,
}, },
}), }),
); );
await repeaterSavePromise;
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
context.onRefresh?.();
context.onFlowRefresh?.();
// 저장 성공 후 모달 닫기 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal"));
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
return true; return true;
} catch (error) { } catch (error) {
console.error("저장 오류:", error); console.error("저장 오류:", error);
@ -1815,6 +1932,50 @@ export class ButtonActionExecutor {
} }
} }
/**
* V2Repeater (onSave )
*/
private static async dispatchRepeaterSave(context: ButtonActionContext): Promise<void> {
const formData = context.formData || {};
const savedId = formData.id;
if (!savedId) {
console.log("⚠️ [dispatchRepeaterSave] savedId(formData.id) 없음 - 스킵");
return;
}
console.log("🟢 [dispatchRepeaterSave] repeaterSave 이벤트 발행:", {
parentId: savedId,
tableName: context.tableName,
masterRecordId: savedId,
formDataKeys: Object.keys(formData),
});
const repeaterSavePromise = new Promise<void>((resolve) => {
const fallbackTimeout = setTimeout(resolve, 5000);
const handler = () => {
clearTimeout(fallbackTimeout);
window.removeEventListener("repeaterSaveComplete", handler);
resolve();
};
window.addEventListener("repeaterSaveComplete", handler);
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: savedId,
tableName: context.tableName,
mainFormData: formData,
masterRecordId: savedId,
},
}),
);
await repeaterSavePromise;
console.log("✅ [dispatchRepeaterSave] repeaterSave 완료");
}
/** /**
* DB에서 formData에서 * DB에서 formData에서
* @param formData * @param formData
@ -2054,11 +2215,11 @@ export class ButtonActionExecutor {
const { tableName, screenId } = context; const { tableName, screenId } = context;
// 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음) // 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음)
// initializeForm에서 __tableSection_ (더블), 수정 시 _tableSection_ (싱글) 사용
const universalFormModalKey = Object.keys(formData).find((key) => { const universalFormModalKey = Object.keys(formData).find((key) => {
const value = formData[key]; const value = formData[key];
if (!value || typeof value !== "object" || Array.isArray(value)) return false; if (!value || typeof value !== "object" || Array.isArray(value)) return false;
// _tableSection_ 키가 있는지 확인 return Object.keys(value).some((k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"));
return Object.keys(value).some((k) => k.startsWith("_tableSection_"));
}); });
if (!universalFormModalKey) { if (!universalFormModalKey) {
@ -2108,24 +2269,72 @@ export class ButtonActionExecutor {
const sections: any[] = modalComponentConfig?.sections || []; const sections: any[] = modalComponentConfig?.sections || [];
const saveConfig = modalComponentConfig?.saveConfig || {}; const saveConfig = modalComponentConfig?.saveConfig || {};
// _tableSection_ 데이터 추출 // 테이블 섹션 데이터 수집: DB 전체 데이터를 베이스로, 수정 데이터를 오버라이드
const tableSectionData: Record<string, any[]> = {}; const tableSectionData: Record<string, any[]> = {};
const commonFieldsData: Record<string, any> = {}; const commonFieldsData: Record<string, any> = {};
// 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용) // 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
// modalData 내부 또는 최상위 formData에서 찾음
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || []; const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
// 1단계: DB 데이터(__tableSection_)와 수정 데이터(_tableSection_)를 별도로 수집
const dbSectionData: Record<string, any[]> = {};
const modifiedSectionData: Record<string, any[]> = {};
// 1-1: modalData(부모의 중첩 객체)에서 수집
for (const [key, value] of Object.entries(modalData)) { for (const [key, value] of Object.entries(modalData)) {
if (key.startsWith("_tableSection_")) { if (key.startsWith("__tableSection_") && Array.isArray(value)) {
const sectionId = key.replace("__tableSection_", "");
dbSectionData[sectionId] = value;
} else if (key.startsWith("_tableSection_") && Array.isArray(value)) {
const sectionId = key.replace("_tableSection_", ""); const sectionId = key.replace("_tableSection_", "");
tableSectionData[sectionId] = value as any[]; modifiedSectionData[sectionId] = value;
} else if (!key.startsWith("_")) { } else if (!key.startsWith("_")) {
// _로 시작하지 않는 필드는 공통 필드로 처리
commonFieldsData[key] = value; commonFieldsData[key] = value;
} }
} }
// 1-2: top-level formData에서도 수집 (handleBeforeFormSave가 직접 설정한 최신 데이터)
// modalData(중첩 객체)가 아직 업데이트되지 않았을 수 있으므로 보완
for (const [key, value] of Object.entries(formData)) {
if (key === universalFormModalKey) continue;
if (key.startsWith("__tableSection_") && Array.isArray(value)) {
const sectionId = key.replace("__tableSection_", "");
if (!dbSectionData[sectionId]) {
dbSectionData[sectionId] = value;
}
} else if (key.startsWith("_tableSection_") && Array.isArray(value)) {
const sectionId = key.replace("_tableSection_", "");
if (!modifiedSectionData[sectionId]) {
modifiedSectionData[sectionId] = value;
}
}
}
// 2단계: DB 데이터를 베이스로, 수정 데이터를 아이템별로 병합하여 전체 데이터 구성
// - DB 데이터(__tableSection_): initializeForm에서 로드한 전체 컬럼 데이터
// - 수정 데이터(_tableSection_): _groupedData 또는 사용자 UI 수정을 통해 설정된 데이터 (일부 컬럼만 포함 가능)
// - 병합: { ...dbItem, ...modItem } → DB 전체 컬럼 유지 + 수정된 필드만 오버라이드
const allSectionIds = new Set([...Object.keys(dbSectionData), ...Object.keys(modifiedSectionData)]);
for (const sectionId of allSectionIds) {
const dbItems = dbSectionData[sectionId] || [];
const modItems = modifiedSectionData[sectionId];
if (modItems) {
tableSectionData[sectionId] = modItems.map((modItem) => {
if (modItem.id) {
const dbItem = dbItems.find((db) => String(db.id) === String(modItem.id));
if (dbItem) {
return { ...dbItem, ...modItem };
}
}
return modItem;
});
} else {
tableSectionData[sectionId] = dbItems;
}
}
// 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음 // 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0); const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
if (!hasTableSectionData && originalGroupedData.length === 0) { if (!hasTableSectionData && originalGroupedData.length === 0) {
@ -2255,28 +2464,26 @@ export class ButtonActionExecutor {
// 각 테이블 섹션 처리 // 각 테이블 섹션 처리
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) { for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
// 🆕 해당 섹션의 설정 찾기
const sectionConfig = sections.find((s) => s.id === sectionId); const sectionConfig = sections.find((s) => s.id === sectionId);
const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable; const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable;
// 🆕 실제 저장할 테이블 결정
// - targetTable이 있으면 해당 테이블에 저장
// - targetTable이 없으면 메인 테이블에 저장
const saveTableName = targetTableName || tableName!; const saveTableName = targetTableName || tableName!;
// 섹션별 DB 원본 데이터 조회 (전체 컬럼 보장)
// _originalTableSectionData_: initializeForm에서 DB 로드 시 저장한 원본 데이터
const sectionOriginalKey = `_originalTableSectionData_${sectionId}`;
const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || [];
// 1⃣ 신규 품목 INSERT (id가 없는 항목) // 1⃣ 신규 품목 INSERT (id가 없는 항목)
const newItems = currentItems.filter((item) => !item.id); const newItems = currentItems.filter((item) => !item.id);
for (const item of newItems) { for (const item of newItems) {
const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
// 내부 메타데이터 제거
Object.keys(rowToSave).forEach((key) => { Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) { if (key.startsWith("_")) {
delete rowToSave[key]; delete rowToSave[key];
} }
}); });
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) { if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId; rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
} }
@ -2296,27 +2503,30 @@ export class ButtonActionExecutor {
insertedCount++; insertedCount++;
} }
// 2⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만) // 2⃣ 기존 품목 UPDATE (id가 있는 항목)
// 전체 데이터 기반 저장: DB 데이터(__tableSection_)를 베이스로 수정 데이터가 병합된 완전한 item 사용
const existingItems = currentItems.filter((item) => item.id); const existingItems = currentItems.filter((item) => item.id);
for (const item of existingItems) { for (const item of existingItems) {
const originalItem = originalGroupedData.find((orig) => orig.id === item.id); // DB 원본 데이터 우선 사용 (전체 컬럼 보장), 없으면 originalGroupedData에서 탐색
const originalItem =
sectionOriginalData.find((orig) => String(orig.id) === String(item.id)) ||
originalGroupedData.find((orig) => String(orig.id) === String(item.id));
// 마스터/디테일 분리 시: 디테일 데이터만 사용 (마스터 필드 병합 안 함)
// 같은 테이블 시: 공통 필드도 병합 (공유 필드 업데이트 필요)
const dataToSave = hasSeparateTargetTable ? item : { ...commonFieldsData, ...item };
if (!originalItem) { if (!originalItem) {
// 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도 // 원본 없음: 전체 데이터로 UPDATE 실행
// originalGroupedData 전달이 누락된 경우를 처리 console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - 전체 데이터로 UPDATE: id=${item.id}`);
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`);
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 const rowToUpdate = { ...dataToSave, ...userInfo };
// item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록
// 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터)
const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo };
Object.keys(rowToUpdate).forEach((key) => { Object.keys(rowToUpdate).forEach((key) => {
if (key.startsWith("_")) { if (key.startsWith("_")) {
delete rowToUpdate[key]; delete rowToUpdate[key];
} }
}); });
// id를 유지하고 UPDATE 실행
const updateResult = await DynamicFormApi.updateFormData(item.id, { const updateResult = await DynamicFormApi.updateFormData(item.id, {
tableName: saveTableName, tableName: saveTableName,
data: rowToUpdate, data: rowToUpdate,
@ -2330,17 +2540,14 @@ export class ButtonActionExecutor {
continue; continue;
} }
// 변경 사항 확인 (공통 필드 포함) // 변경 사항 확인: 원본(DB) vs 현재(병합된 전체 데이터)
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀) const hasChanges = this.checkForChanges(originalItem, dataToSave);
const currentDataWithCommon = { ...item, ...commonFieldsData };
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
if (hasChanges) { if (hasChanges) {
// 변경된 필드만 추출하여 부분 업데이트
const updateResult = await DynamicFormApi.updateFormDataPartial( const updateResult = await DynamicFormApi.updateFormDataPartial(
item.id, item.id,
originalItem, originalItem,
currentDataWithCommon, dataToSave,
saveTableName, saveTableName,
); );
@ -2349,16 +2556,11 @@ export class ButtonActionExecutor {
} }
updatedCount++; updatedCount++;
} else {
} }
} }
// 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목) // 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
// 🆕 테이블 섹션별 원본 데이터 사용 (우선), 없으면 전역 originalGroupedData 사용 // 섹션별 DB 원본 데이터 사용 (위에서 이미 조회), 없으면 전역 originalGroupedData 사용
const sectionOriginalKey = `_originalTableSectionData_${sectionId}`;
const sectionOriginalData: any[] = modalData[sectionOriginalKey] || formData[sectionOriginalKey] || [];
// 섹션별 원본 데이터가 있으면 사용, 없으면 전역 originalGroupedData 사용
const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData; const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData;
// ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지) // ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)
@ -5094,7 +5296,8 @@ export class ButtonActionExecutor {
// 시스템 컬럼 제외 // 시스템 컬럼 제외
if (SYSTEM_COLUMNS.includes(lowerKey)) return false; if (SYSTEM_COLUMNS.includes(lowerKey)) return false;
// _name, _label 등 조인된 보조 필드 제외 // _name, _label 등 조인된 보조 필드 제외
if (lowerKey.endsWith("_name") || lowerKey.endsWith("_label") || lowerKey.endsWith("_value_label")) return false; if (lowerKey.endsWith("_name") || lowerKey.endsWith("_label") || lowerKey.endsWith("_value_label"))
return false;
return true; return true;
}); });
// 라벨이 없으므로 최소한 column_labels 비워두지 않음 (컬럼명 그대로 표시되지만 시스템 컬럼은 제외됨) // 라벨이 없으므로 최소한 column_labels 비워두지 않음 (컬럼명 그대로 표시되지만 시스템 컬럼은 제외됨)
@ -6177,7 +6380,14 @@ export class ButtonActionExecutor {
const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer; const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer;
if (targetType === "component" && targetComponentId) { if (targetType === "component" && targetComponentId) {
// 같은 화면 내 컴포넌트로 전달 // 같은 화면 내 컴포넌트로 전달 + 레이어 활성화 이벤트 병행
const activateEvent = new CustomEvent("activateLayerForComponent", {
detail: {
componentId: targetComponentId,
targetLayerId: (dataTransfer as any).targetLayerId,
},
});
window.dispatchEvent(activateEvent);
const transferEvent = new CustomEvent("componentDataTransfer", { const transferEvent = new CustomEvent("componentDataTransfer", {
detail: { detail: {

View File

@ -57,6 +57,15 @@ export interface MappingRule {
required?: boolean; // 필수 여부 required?: boolean; // 필수 여부
} }
/**
*
*
*/
export interface MultiTableMappingGroup {
sourceTable: string;
mappingRules: MappingRule[];
}
/** /**
* *
* *
@ -155,6 +164,7 @@ export interface DataReceivable {
export interface DataProvidable { export interface DataProvidable {
componentId: string; componentId: string;
componentType: string; componentType: string;
tableName?: string;
/** /**
* *

View File

@ -52,6 +52,9 @@ export interface NumberingRulePart {
partType: CodePartType; // 파트 유형 partType: CodePartType; // 파트 유형
generationMethod: GenerationMethod; // 생성 방식 generationMethod: GenerationMethod; // 생성 방식
// 이 파트 뒤에 붙을 구분자 (마지막 파트는 무시됨)
separatorAfter?: string;
// 자동 생성 설정 // 자동 생성 설정
autoConfig?: { autoConfig?: {
// 순번용 // 순번용

View File

@ -182,6 +182,8 @@ export interface V2SelectProps extends V2BaseProps {
config: V2SelectConfig; config: V2SelectConfig;
value?: string | string[]; value?: string | string[];
onChange?: (value: string | string[]) => void; onChange?: (value: string | string[]) => void;
onFormDataChange?: (fieldName: string, value: any) => void;
formData?: Record<string, any>;
} }
// ===== V2Date ===== // ===== V2Date =====

View File

@ -181,6 +181,7 @@ export interface V2RepeaterConfig {
// 컴포넌트 Props // 컴포넌트 Props
export interface V2RepeaterProps { export interface V2RepeaterProps {
config: V2RepeaterConfig; config: V2RepeaterConfig;
componentId?: string; // ScreenContext DataReceiver 등록용
parentId?: string | number; // 부모 레코드 ID parentId?: string | number; // 부모 레코드 ID
data?: any[]; // 초기 데이터 (없으면 API로 로드) data?: any[]; // 초기 데이터 (없으면 API로 로드)
onDataChange?: (data: any[]) => void; onDataChange?: (data: any[]) => void;

64
package-lock.json generated
View File

@ -17,7 +17,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/oracledb": "^6.9.1", "@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5" "@types/pg": "^8.15.5",
"playwright": "^1.58.2"
} }
}, },
"node_modules/@azure-rest/core-client": { "node_modules/@azure-rest/core-client": {
@ -470,6 +471,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.17.8", "@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0", "@types/react-reconciler": "^0.32.0",
@ -710,6 +712,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0", "@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3", "@tweenjs/tween.js": "~23.1.3",
@ -1076,8 +1079,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
@ -1136,6 +1138,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -1473,6 +1476,21 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -2061,6 +2079,38 @@
"pathe": "^2.0.3" "pathe": "^2.0.3"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postgres-array": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@ -2116,6 +2166,7 @@
"integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==", "integrity": "sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "6.16.2", "@prisma/config": "6.16.2",
"@prisma/engines": "6.16.2" "@prisma/engines": "6.16.2"
@ -2331,8 +2382,7 @@
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.2", "version": "7.7.2",
@ -2451,7 +2501,8 @@
"version": "0.180.0", "version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/three-mesh-bvh": { "node_modules/three-mesh-bvh": {
"version": "0.8.3", "version": "0.8.3",
@ -2547,6 +2598,7 @@
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }

View File

@ -12,6 +12,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/oracledb": "^6.9.1", "@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5" "@types/pg": "^8.15.5",
"playwright": "^1.58.2"
} }
} }