diff --git a/.gitignore b/.gitignore index 18a191ed..a4c207df 100644 --- a/.gitignore +++ b/.gitignore @@ -291,6 +291,11 @@ uploads/ claude.md +# AI 에이전트 테스트 산출물 +*-test-screenshots/ +*-screenshots/ +*-test.mjs + # 개인 작업 문서 (popdocs) popdocs/ .cursor/rules/popdocs-safety.mdc \ No newline at end of file diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index e454742a..4b3d212a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -105,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 +import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 @@ -289,6 +290,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 +app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts new file mode 100644 index 00000000..8355b148 --- /dev/null +++ b/backend-node/src/controllers/bomController.ts @@ -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 }); + } +} diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index bbc42568..3ece2ce7 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -115,7 +115,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; - const { value = "id", label = "name" } = req.query; + const { value = "id", label = "name", fields } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -167,9 +167,21 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) ? `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개) const query = ` - SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label + SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns} FROM ${tableName} ${whereClause} ORDER BY ${effectiveLabelColumn} ASC @@ -184,6 +196,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) labelColumn: effectiveLabelColumn, companyCode, rowCount: result.rowCount, + extraFields: extraColumns ? true : false, }); res.json({ diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index 7c38d6d9..e72f6b9f 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -39,16 +39,18 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon if (search) params.push(`%${search}%`); const query = ` - SELECT DISTINCT + SELECT i.id, i.${nameColumn} AS item_name, - i.${codeColumn} AS item_code + i.${codeColumn} AS item_code, + COUNT(rv.id) AS routing_count 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 WHERE i.company_code = $1 ${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); @@ -82,10 +84,10 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R // 라우팅 버전 목록 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} 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, [ 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 // ============================================================ @@ -330,7 +418,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons const { workItemId } = req.params; 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 WHERE work_item_id = $1 AND company_code = $2 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: "인증 필요" }); } - 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) { return res.status(400).json({ @@ -375,8 +470,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo const query = ` INSERT INTO process_work_item_detail - (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer, + 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 * `; @@ -389,6 +486,15 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo sort_order || 0, remark || null, 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 }); @@ -410,7 +516,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo } 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 = ` UPDATE process_work_item_detail @@ -419,6 +529,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo is_required = COALESCE($3, is_required), sort_order = COALESCE($4, sort_order), 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() WHERE id = $6 AND company_code = $7 RETURNING * @@ -432,6 +551,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo remark, id, 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) { @@ -544,8 +672,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { for (const detail of item.details) { await client.query( `INSERT INTO process_work_item_detail - (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer, + 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, workItemId, @@ -555,6 +685,15 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { detail.sort_order || 0, detail.remark || null, 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, ] ); } diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 320ab74b..8cd9f770 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -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 = { + const response: ApiResponse<{ id: string | null }> = { success: true, message: "테이블 데이터를 성공적으로 추가했습니다.", + data: { id: result.insertedId }, }; res.status(201).json(response); @@ -1003,6 +1040,45 @@ export async function editTableData( } 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( @@ -1693,6 +1769,7 @@ export async function getCategoryColumnsByCompany( let columnsResult; // 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회 + // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT @@ -1712,15 +1789,15 @@ export async function getCategoryColumnsByCompany( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); - logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", { + logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { - // 일반 회사: 해당 회사의 카테고리 컬럼만 조회 const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1739,11 +1816,12 @@ export async function getCategoryColumnsByCompany( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); - logger.info("✅ 회사별 카테고리 컬럼 조회 완료", { + logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); @@ -1804,13 +1882,10 @@ export async function getCategoryColumnsByMenu( const { getPool } = await import("../database/db"); const pool = getPool(); - // 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회 - // category_column_mapping 대신 table_type_columns 기준으로 조회 - logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); - + // table_type_columns에서 input_type = 'category' 컬럼 조회 + // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) let columnsResult; - // 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회 if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT @@ -1830,15 +1905,15 @@ export async function getCategoryColumnsByMenu( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); - logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", { + logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { - // 일반 회사: 해당 회사의 카테고리 컬럼만 조회 const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1857,11 +1932,12 @@ export async function getCategoryColumnsByMenu( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); - logger.info("✅ 회사별 카테고리 컬럼 조회 완료", { + logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); @@ -2616,8 +2692,22 @@ export async function toggleTableIndex( logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); 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 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}`); await query(sql); } else if (action === "drop") { @@ -2638,22 +2728,55 @@ export async function toggleTableIndex( } catch (error: any) { logger.error("인덱스 토글 오류:", error); - // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 - const errorMsg = error.message?.includes("duplicate key") - ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." - : "인덱스 설정 중 오류가 발생했습니다."; + const errMsg = error.message || ""; + let userMessage = "인덱스 설정 중 오류가 발생했습니다."; + 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({ success: false, - message: errorMsg, - error: error instanceof Error ? error.message : "Unknown error", + message: userMessage, + error: errMsg, + duplicates, }); } } /** - * NOT NULL 토글 + * NOT NULL 토글 (회사별 소프트 제약조건) * 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( req: AuthenticatedRequest, @@ -2662,6 +2785,7 @@ export async function toggleColumnNullable( try { const { tableName, columnName } = req.params; const { nullable } = req.body; + const companyCode = req.user?.companyCode || "*"; if (!tableName || !columnName || typeof nullable !== "boolean") { res.status(400).json({ @@ -2671,18 +2795,54 @@ export async function toggleColumnNullable( return; } - if (nullable) { - // NOT NULL 해제 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; - logger.info(`NOT NULL 해제: ${sql}`); - await query(sql); - } else { - // NOT NULL 설정 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; - logger.info(`NOT NULL 설정: ${sql}`); - await query(sql); + // is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL + const isNullableValue = nullable ? "Y" : "N"; + + if (!nullable) { + // NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인 + 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 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({ success: true, message: nullable @@ -2692,14 +2852,95 @@ export async function toggleColumnNullable( } catch (error: any) { 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({ 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 { + 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(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", }); } diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts new file mode 100644 index 00000000..f6e3ee62 --- /dev/null +++ b/backend-node/src/routes/bomRoutes.ts @@ -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; diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index 0c052007..7630b359 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -14,6 +14,10 @@ router.use(authenticateToken); router.get("/items", ctrl.getItemsWithRouting); router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); +// 기본 버전 설정/해제 +router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion); +router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion); + // 작업 항목 CRUD router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems); router.post("/work-items", ctrl.createWorkItem); diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d02a5615..a8964e99 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -32,6 +32,7 @@ import { setTablePrimaryKey, // 🆕 PK 설정 toggleTableIndex, // 🆕 인덱스 토글 toggleColumnNullable, // 🆕 NOT NULL 토글 + toggleColumnUnique, // 🆕 UNIQUE 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex); */ 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 diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts new file mode 100644 index 00000000..89da38a9 --- /dev/null +++ b/backend-node/src/services/bomService.ts @@ -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>(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 = {}; + 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; + }); +} diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index a8765d18..2888a1f3 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -14,6 +14,35 @@ interface NumberingRulePart { autoConfig?: any; manualConfig?: any; 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 { @@ -141,7 +170,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { @@ -274,7 +303,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; @@ -381,7 +410,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("✅ 규칙 파트 조회 성공", { ruleId: rule.ruleId, @@ -517,7 +546,7 @@ class NumberingRuleService { companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, { @@ -633,7 +662,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); return rule; } @@ -708,17 +737,25 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + // auto_config에 separatorAfter 포함 + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), 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"); @@ -820,17 +857,23 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), 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("코드 미리보기 생성", { ruleId, previewCode, @@ -1164,8 +1208,8 @@ class NumberingRuleService { } })); - const separator = rule.separator || ""; - const previewTemplate = previewParts.join(separator); + const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); // 사용자 입력 코드에서 수동 입력 부분 추출 // 예: 템플릿 "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( @@ -1541,7 +1586,7 @@ class NumberingRuleService { rule.ruleId, companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info("[테스트] 채번 규칙 목록 조회 완료", { @@ -1634,7 +1679,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { ruleId: rule.ruleId, @@ -1754,12 +1799,14 @@ class NumberingRuleService { auto_config, manual_config, company_code, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + await client.query(partInsertQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); @@ -1914,7 +1961,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("카테고리 조건 매칭 채번 규칙 찾음", { ruleId: rule.ruleId, @@ -1973,7 +2020,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { ruleId: rule.ruleId, @@ -2056,7 +2103,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e0f3944..791940ec 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -199,7 +199,15 @@ export class TableManagementService { cl.input_type as "cl_input_type", COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings", 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", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -241,7 +249,15 @@ export class TableManagementService { COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings::text, '') as "detailSettings", 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", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -502,8 +518,8 @@ export class TableManagementService { table_name, column_name, column_label, input_type, detail_settings, code_category, code_value, reference_table, reference_column, display_column, display_order, is_visible, is_nullable, - company_code, created_date, updated_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW()) + company_code, category_ref, created_date, updated_date + ) 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) DO UPDATE SET 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_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), + category_ref = EXCLUDED.category_ref, updated_date = NOW()`, [ tableName, @@ -531,6 +548,7 @@ export class TableManagementService { settings.displayOrder || 0, settings.isVisible !== undefined ? settings.isVisible : true, companyCode, + settings.categoryRef || null, ] ); @@ -1599,7 +1617,8 @@ export class TableManagementService { tableName, columnName, actualValue, - paramIndex + paramIndex, + operator ); case "entity": @@ -1612,7 +1631,14 @@ export class TableManagementService { ); default: - // 기본 문자열 검색 (actualValue 사용) + // operator에 따라 정확 일치 또는 부분 일치 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(actualValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${actualValue}%`], @@ -1626,10 +1652,19 @@ export class TableManagementService { ); // 오류 시 기본 검색으로 폴백 let fallbackValue = value; + let fallbackOperator = "contains"; if (typeof value === "object" && value !== null && "value" in value) { fallbackValue = value.value; + fallbackOperator = value.operator || "contains"; } + if (fallbackOperator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(fallbackValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${fallbackValue}%`], @@ -1776,7 +1811,8 @@ export class TableManagementService { tableName: string, columnName: string, value: any, - paramIndex: number + paramIndex: number, + operator: string = "contains" ): Promise<{ whereClause: string; values: any[]; @@ -1786,7 +1822,14 @@ export class TableManagementService { const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName); if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) { - // 코드 타입이 아니면 기본 검색 + // 코드 타입이 아니면 operator에 따라 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(value)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, 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() !== "") { // 코드값 또는 코드명으로 검색 return { @@ -2431,6 +2483,154 @@ export class TableManagementService { return value; } + /** + * 회사별 NOT NULL 소프트 제약조건 검증 + * table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다. + */ + async validateNotNullConstraints( + tableName: string, + data: Record, + companyCode: string + ): Promise { + 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, + companyCode: string, + excludeId?: string + ): Promise { + 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 무시된 컬럼 정보 (디버깅용) @@ -2438,7 +2638,7 @@ export class TableManagementService { async addTableData( tableName: string, data: Record - ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> { + ): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> { try { logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`추가할 데이터:`, data); @@ -2551,19 +2751,21 @@ export class TableManagementService { const insertQuery = ` INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders}) + RETURNING id `; logger.info(`실행할 쿼리: ${insertQuery}`); 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 { skippedColumns, savedColumns: existingColumns, + insertedId, }; } catch (error) { logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); @@ -4353,7 +4555,8 @@ export class TableManagementService { END as "detailSettings", ttc.is_nullable as "isNullable", 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 LEFT JOIN information_schema.columns ic 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 baseInfo = { + const baseInfo: any = { tableName: tableName, columnName: col.columnName, displayName: col.displayName, dataType: col.dataType || "varchar", inputType: col.inputType, detailSettings: col.detailSettings, - description: "", // 필수 필드 추가 - isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환 + description: "", + isNullable: col.isNullable === "Y" ? "Y" : "N", isPrimaryKey: false, displayOrder: 0, isVisible: true, }; + if (col.categoryRef) { + baseInfo.categoryRef = col.categoryRef; + } + // 카테고리 타입인 경우 categoryMenus 추가 if ( col.inputType === "category" && diff --git a/backend-node/src/types/tableManagement.ts b/backend-node/src/types/tableManagement.ts index 8c786063..977031b8 100644 --- a/backend-node/src/types/tableManagement.ts +++ b/backend-node/src/types/tableManagement.ts @@ -44,6 +44,7 @@ export interface ColumnSettings { displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 displayOrder?: number; // 표시 순서 isVisible?: boolean; // 표시 여부 + categoryRef?: string | null; // 카테고리 참조 } export interface TableLabels { diff --git a/docs/BOM_개발_현황.md b/docs/BOM_개발_현황.md new file mode 100644 index 00000000..45c33546 --- /dev/null +++ b/docs/BOM_개발_현황.md @@ -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 유효성 검증 (순환참조 방지 등) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index cf89df73..d5c41e6a 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -63,6 +63,7 @@ interface ColumnTypeInfo { detailSettings: string; description: string; isNullable: string; + isUnique: string; defaultValue?: string; maxLength?: number; numericPrecision?: number; @@ -72,9 +73,10 @@ interface ColumnTypeInfo { referenceTable?: string; referenceColumn?: string; displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 - categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열 - hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할 - numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID + categoryMenus?: number[]; + hierarchyRole?: "large" | "medium" | "small"; + numberingRuleId?: string; + categoryRef?: string | null; } interface SecondLevelMenu { @@ -382,10 +384,12 @@ export default function TableManagementPage() { return { ...col, - inputType: col.inputType || "text", // 기본값: text - numberingRuleId, // 🆕 채번규칙 ID - categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 - hierarchyRole, // 계층구조 역할 + inputType: col.inputType || "text", + isUnique: col.isUnique || "NO", + numberingRuleId, + categoryMenus: col.categoryMenus || [], + hierarchyRole, + categoryRef: col.categoryRef || null, }; }); @@ -668,15 +672,16 @@ export default function TableManagementPage() { } const columnSetting = { - columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) - columnLabel: column.displayName, // 사용자가 입력한 표시명 + columnName: column.columnName, + columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", referenceColumn: column.referenceColumn || "", - displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명 + displayColumn: column.displayColumn || "", + categoryRef: column.categoryRef || null, }; // console.log("저장할 컬럼 설정:", columnSetting); @@ -703,9 +708,9 @@ export default function TableManagementPage() { length: column.categoryMenus?.length || 0, }); - if (column.inputType === "category") { - // 1. 먼저 기존 매핑 모두 삭제 - console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", { + if (column.inputType === "category" && !column.categoryRef) { + // 참조가 아닌 자체 카테고리만 메뉴 매핑 처리 + console.log("기존 카테고리 메뉴 매핑 삭제 시작:", { tableName: selectedTable, columnName: column.columnName, }); @@ -864,8 +869,8 @@ export default function TableManagementPage() { } return { - columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) - columnLabel: column.displayName, // 사용자가 입력한 표시명 + columnName: column.columnName, + columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, description: column.description || "", @@ -873,7 +878,8 @@ export default function TableManagementPage() { codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", 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) { - // 🆕 Category 타입 컬럼들의 메뉴 매핑 처리 - const categoryColumns = columns.filter((col) => col.inputType === "category"); + // 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외) + const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef); console.log("📥 전체 저장: 카테고리 컬럼 확인", { totalColumns: columns.length, @@ -1091,9 +1097,9 @@ export default function TableManagementPage() { } }; - // 인덱스 토글 핸들러 + // 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨) const handleIndexToggle = useCallback( - async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + async (columnName: string, indexType: "index", checked: boolean) => { if (!selectedTable) return; const action = checked ? "create" : "drop"; try { @@ -1122,14 +1128,41 @@ export default function TableManagementPage() { const hasIndex = constraints.indexes.some( (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, ); - const hasUnique = constraints.indexes.some( - (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, - ); - return { isPk, hasIndex, hasUnique }; + return { isPk, hasIndex }; }, [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 토글 핸들러 const handleNullableToggle = useCallback( async (columnName: string, currentIsNullable: string) => { @@ -1662,7 +1695,30 @@ export default function TableManagementPage() { )} )} - {/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */} + {/* 카테고리 타입: 참조 설정 */} + {column.inputType === "category" && ( +
+ + { + 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" + /> +

+ 다른 테이블의 카테고리 값 참조 시 입력 +

+
+ )} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {column.inputType === "entity" && ( <> @@ -2029,12 +2085,12 @@ export default function TableManagementPage() { aria-label={`${column.columnName} 인덱스 설정`} /> - {/* UQ 체크박스 */} + {/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
- handleIndexToggle(column.columnName, "unique", checked as boolean) + checked={column.isUnique === "YES"} + onCheckedChange={() => + handleUniqueToggle(column.columnName, column.isUnique) } aria-label={`${column.columnName} 유니크 설정`} /> diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 95305aaf..160883ad 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -87,10 +87,12 @@ function ScreenViewPage() { // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); - // 🆕 레이어 시스템 지원 + // 레이어 시스템 지원 const [conditionalLayers, setConditionalLayers] = useState([]); - // 🆕 조건부 영역(Zone) 목록 + // 조건부 영역(Zone) 목록 const [zones, setZones] = useState([]); + // 데이터 전달에 의해 강제 활성화된 레이어 ID 목록 + const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState([]); // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); @@ -378,11 +380,51 @@ function ScreenViewPage() { } }); - return newActiveIds; - }, [formData, conditionalLayers, layout]); + // 강제 활성화된 레이어 ID 병합 + 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(() => { const loadMainTableData = async () => { if (!screen || !layout || !layout.components || !companyCode) { diff --git a/frontend/components/auth/AuthGuard.tsx b/frontend/components/auth/AuthGuard.tsx index efb8cd25..3b3eb182 100644 --- a/frontend/components/auth/AuthGuard.tsx +++ b/frontend/components/auth/AuthGuard.tsx @@ -3,6 +3,7 @@ import { useEffect, ReactNode } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; +import { AuthLogger } from "@/lib/authLogger"; import { Loader2 } from "lucide-react"; interface AuthGuardProps { @@ -41,11 +42,13 @@ export function AuthGuard({ } if (requireAuth && !isLoggedIn) { + AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`); router.push(redirectTo); return; } if (requireAdmin && !isAdmin) { + AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`); router.push(redirectTo); return; } diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 4797a34a..81b5ed61 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -942,17 +942,22 @@ export const ExcelUploadModal: React.FC = ({ continue; } - // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용) + // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { - try { - 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; + const existingValue = dataToSave[numberingInfo.columnName]; + const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; + + if (!hasExcelValue) { + try { + 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); } } diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 16dd5afc..a79f26e3 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; interface ScreenModalState { isOpen: boolean; @@ -1025,6 +1026,10 @@ export const ScreenModal: React.FC = ({ className }) => {
) : screenData ? ( +
= ({ className }) => {
+
) : (

화면 데이터가 없습니다.

diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index d1444d4e..e9731017 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC = ({ isPreview = false, }) => { return ( - +
diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 9320f00e..8b521fe0 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC = ({ const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false); - // 구분자 관련 상태 - const [separatorType, setSeparatorType] = useState("-"); - const [customSeparator, setCustomSeparator] = useState(""); + // 구분자 관련 상태 (개별 파트 사이 구분자) + const [separatorTypes, setSeparatorTypes] = useState>({}); + const [customSeparators, setCustomSeparators] = useState>({}); // 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회 interface CategoryOption { @@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC = ({ } }, [currentRule, onChange]); - // currentRule이 변경될 때 구분자 상태 동기화 + // currentRule이 변경될 때 파트별 구분자 상태 동기화 useEffect(() => { - if (currentRule) { - const sep = currentRule.separator ?? "-"; - // 빈 문자열이면 "none" - 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); - } + if (currentRule && currentRule.parts.length > 0) { + const newSepTypes: Record = {}; + const newCustomSeps: Record = {}; + + currentRule.parts.forEach((part) => { + const sep = part.separatorAfter ?? currentRule.separator ?? "-"; + 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]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시) + }, [currentRule?.ruleId]); - // 구분자 변경 핸들러 - const handleSeparatorChange = useCallback((type: SeparatorType) => { - setSeparatorType(type); + // 개별 파트 구분자 변경 핸들러 + const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => { + setSeparatorTypes(prev => ({ ...prev, [partOrder]: type })); if (type !== "custom") { const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); const newSeparator = option?.displayValue ?? ""; - setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null); - setCustomSeparator(""); + setCustomSeparators(prev => ({ ...prev, [partOrder]: "" })); + 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) => { - // 최대 2자 제한 + // 개별 파트 직접 입력 구분자 변경 핸들러 + const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => { const trimmedValue = value.slice(0, 2); - setCustomSeparator(trimmedValue); - setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null); + setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue })); + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => + part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part + ), + }; + }); }, []); const handleAddPart = useCallback(() => { @@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC = ({ partType: "text", generationMethod: "auto", autoConfig: { textValue: "CODE" }, + separatorAfter: "-", }; setCurrentRule((prev) => { @@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC = ({ return { ...prev, parts: [...prev.parts, newPart] }; }); + // 새 파트의 구분자 상태 초기화 + setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" })); + setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" })); + toast.success(`규칙 ${newPart.order}가 추가되었습니다`); }, [currentRule, maxRules]); @@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC = ({
- {/* 두 번째 줄: 구분자 설정 */} -
-
- - -
- {separatorType === "custom" && ( -
- - handleCustomSeparatorChange(e.target.value)} - className="h-9" - placeholder="최대 2자" - maxLength={2} - /> -
- )} -

- 규칙 사이에 들어갈 문자입니다 -

-
@@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC = ({

규칙을 추가하여 코드를 구성하세요

) : ( -
+
{currentRule.parts.map((part, index) => ( - handleUpdatePart(part.order, updates)} - onDelete={() => handleDeletePart(part.order)} - isPreview={isPreview} - /> + +
+ handleUpdatePart(part.order, updates)} + onDelete={() => handleDeletePart(part.order)} + isPreview={isPreview} + /> + {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} + {index < currentRule.parts.length - 1 && ( +
+ 뒤 구분자 + + {separatorTypes[part.order] === "custom" && ( + handlePartCustomSeparatorChange(part.order, e.target.value)} + className="h-6 w-14 text-center text-[10px]" + placeholder="2자" + maxLength={2} + /> + )} +
+ )} +
+
))}
)} diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index a9179959..eff551a1 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC = ({ return "규칙을 추가해주세요"; } - const parts = config.parts - .sort((a, b) => a.order - b.order) - .map((part) => { - if (part.generationMethod === "manual") { - return part.manualConfig?.value || "XXX"; + const sortedParts = config.parts.sort((a, b) => a.order - b.order); + + const partValues = sortedParts.map((part) => { + if (part.generationMethod === "manual") { + 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"); } - - const autoConfig = part.autoConfig || {}; - - switch (part.partType) { - // 1. 순번 (자동 증가) - case "sequence": { - const length = autoConfig.sequenceLength || 3; - const startFrom = autoConfig.startFrom || 1; - 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"); - + case "number": { + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 0; + return String(value).padStart(length, "0"); + } + case "date": { + const format = autoConfig.dateFormat || "YYYYMMDD"; + if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { switch (format) { - case "YYYY": return String(year); - 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 "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]"; } } - - // 4. 문자 - case "text": - return autoConfig.textValue || "TEXT"; - - default: - return "XXX"; + 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) { + case "YYYY": return String(year); + 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]); if (compact) { diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 8dad77db..49aed98b 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -17,6 +17,7 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm"; import { useAuth } from "@/hooks/useAuth"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; interface EditModalState { isOpen: boolean; @@ -1154,19 +1155,6 @@ export const EditModal: React.FC = ({ className }) => { if (response.success) { 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("데이터가 생성되었습니다."); // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) @@ -1214,6 +1202,40 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } + // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) + try { + const repeaterSavePromise = new Promise((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(); } else { throw new Error(response.message || "생성에 실패했습니다."); @@ -1319,6 +1341,40 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } + // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) + try { + const repeaterSavePromise = new Promise((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(); } else { throw new Error(response.message || "수정에 실패했습니다."); @@ -1385,12 +1441,16 @@ export const EditModal: React.FC = ({ className }) => {
) : screenData ? ( +
{ const baseHeight = (screenDimensions?.height || 600) + 30; if (activeConditionalComponents.length > 0) { @@ -1546,6 +1606,7 @@ export const EditModal: React.FC = ({ className }) => { ); })}
+
) : (

화면 데이터가 없습니다.

diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index e2143e8e..05d228f4 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -571,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC { + 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 { - // 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) + // 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함 const masterFormData: Record = {}; @@ -591,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC { if (!Array.isArray(value)) { - // 배열이 아닌 값은 그대로 저장 masterFormData[key] = value; } else if (mediaColumnNames.has(key)) { - // v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응) - // 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용 masterFormData[key] = value.length > 0 ? value[0] : null; console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`); } else { @@ -608,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC(null); + // 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용) + const [otherLayerComponents, setOtherLayerComponents] = useState([]); + // 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기 useEffect(() => { if (activeLayerId <= 1 || !selectedScreen?.screenId) { @@ -578,6 +581,41 @@ export default function ScreenDesigner({ findZone(); }, [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 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) const visibleComponents = useMemo(() => { return layout.components; @@ -3968,10 +4006,10 @@ export default function ScreenDesigner({ label: column.columnLabel || column.columnName, tableName: table.tableName, columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 + required: isEntityJoinColumn ? false : column.required, + readonly: false, + parentId: formContainerId, + componentType: v2Mapping.componentType, position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) @@ -3995,12 +4033,11 @@ export default function ScreenDesigner({ }, componentConfig: { type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + ...v2Mapping.componentConfig, }, }; } else { - return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 + return; } } else { // 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용 @@ -4036,9 +4073,9 @@ export default function ScreenDesigner({ label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 tableName: table.tableName, columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 + required: isEntityJoinColumn ? false : column.required, + readonly: false, + componentType: v2Mapping.componentType, position: { x, y, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) @@ -4062,8 +4099,7 @@ export default function ScreenDesigner({ }, componentConfig: { type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + ...v2Mapping.componentConfig, }, }; } @@ -6518,8 +6554,8 @@ export default function ScreenDesigner({ updateComponentProperty(selectedComponent.id, "style", style); } }} - allComponents={layout.components} // 🆕 플로우 위젯 감지용 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 + allComponents={[...layout.components, ...otherLayerComponents]} + menuObjid={menuObjid} /> )} diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index ea2febb1..4919ec33 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -92,13 +92,14 @@ export const ButtonConfigPanel: React.FC = ({ const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState>({}); // 블록별 테이블 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState>({}); // 블록별 컬럼 Popover 열림 상태 - // 🆕 데이터 전달 필드 매핑용 상태 - const [mappingSourceColumns, setMappingSourceColumns] = useState>([]); + // 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원) + const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState>>({}); const [mappingTargetColumns, setMappingTargetColumns] = useState>([]); - const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); - const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); - const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); - const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); + const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); + const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); + const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0); // 🆕 openModalWithData 전용 필드 매핑 상태 const [modalSourceColumns, setModalSourceColumns] = useState>([]); @@ -295,57 +296,57 @@ export const ButtonConfigPanel: React.FC = ({ } }; - // 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드 - useEffect(() => { - const sourceTable = config.action?.dataTransfer?.sourceTable; - const targetTable = config.action?.dataTransfer?.targetTable; + // 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드 + const loadMappingColumns = useCallback(async (tableName: string): Promise> => { + try { + 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 (sourceTable) { - try { - const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); - if (response.data.success) { - let columnData = response.data.data; - if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; - if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - - if (Array.isArray(columnData)) { - const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, - })); - setMappingSourceColumns(columns); - } - } - } catch (error) { - console.error("소스 테이블 컬럼 로드 실패:", error); + if (Array.isArray(columnData)) { + return columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); } } + } catch (error) { + console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error); + } + return []; + }, []); - if (targetTable) { - try { - const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`); - if (response.data.success) { - let columnData = response.data.data; - if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; - if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + useEffect(() => { + const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || []; + const legacySourceTable = config.action?.dataTransfer?.sourceTable; + const targetTable = config.action?.dataTransfer?.targetTable; - if (Array.isArray(columnData)) { - const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, - })); - setMappingTargetColumns(columns); - } - } - } catch (error) { - console.error("타겟 테이블 컬럼 로드 실패:", error); + const loadAll = async () => { + const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean); + if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) { + sourceTableNames.push(legacySourceTable); + } + + const newMap: Record> = {}; + for (const tbl of sourceTableNames) { + if (!mappingSourceColumnsMap[tbl]) { + 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(); - }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); + loadAll(); + }, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]); // 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드 useEffect(() => { @@ -2966,11 +2967,17 @@ export const ButtonConfigPanel: React.FC = ({ - {/* 데이터 제공 가능한 컴포넌트 필터링 */} + {/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */} + +
+ 자동 탐색 (현재 활성 테이블) + (auto) +
+
+ {/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */} {allComponents .filter((comp: any) => { const type = comp.componentType || comp.type || ""; - // 데이터를 제공할 수 있는 컴포넌트 타입들 return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t), ); @@ -2978,11 +2985,17 @@ export const ButtonConfigPanel: React.FC = ({ .map((comp: any) => { const compType = comp.componentType || comp.type || "unknown"; const compLabel = comp.label || comp.componentConfig?.title || comp.id; + const layerName = comp._layerName; return (
{compLabel} ({compType}) + {layerName && ( + + {layerName} + + )}
); @@ -2999,7 +3012,9 @@ export const ButtonConfigPanel: React.FC = ({ )}
-

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

+

+ 레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다 +

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

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

+ + + + + + + + + 컬럼을 찾을 수 없습니다. + + { + 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" + > + + 선택 안 함 (전체 데이터 병합) + + {(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => ( + { + 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" + > + + {col.label || col.name} + {col.label && col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +

추가 데이터가 저장될 타겟 테이블 컬럼

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

- 소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다. + 여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.

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

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

+

먼저 타겟 테이블을 선택하세요.

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

- 매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다. + 매핑 그룹이 없습니다. 소스 테이블을 추가하세요.

) : (
- {(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => ( -
- {/* 소스 필드 선택 (Combobox) */} -
- setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} + {/* 소스 테이블 탭 */} +
+ {(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => ( +
+ - - - - - setMappingSourceSearch((prev) => ({ ...prev, [index]: value })) - } - /> - - - 컬럼을 찾을 수 없습니다 - - - {mappingSourceColumns.map((col) => ( - { - const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; - rules[index] = { ...rules[index], sourceField: col.name }; - onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); - setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false })); - }} - className="text-xs" - > - - {col.label} - {col.label !== col.name && ( - ({col.name}) - )} - - ))} - - - - - -
- - - - {/* 타겟 필드 선택 (Combobox) */} -
- setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} + {group.sourceTable + ? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable + : `그룹 ${gIdx + 1}`} + {group.mappingRules?.length > 0 && ( + + {group.mappingRules.length} + + )} + + - - - - - setMappingTargetSearch((prev) => ({ ...prev, [index]: value })) - } - /> - - - 컬럼을 찾을 수 없습니다 - - - {mappingTargetColumns.map((col) => ( - { - const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; - rules[index] = { ...rules[index], targetField: col.name }; - onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); - setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false })); - }} - className="text-xs" - > - - {col.label} - {col.label !== col.name && ( - ({col.name}) - )} - - ))} - - - - - + +
+ ))} +
- -
- ))} + {/* 활성 그룹 편집 영역 */} + {(() => { + const multiMappings = config.action?.dataTransfer?.multiTableMappings || []; + const activeGroup = multiMappings[activeMappingGroupIndex]; + if (!activeGroup) return null; + + const activeSourceTable = activeGroup.sourceTable || ""; + const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || []; + const activeRules: any[] = activeGroup.mappingRules || []; + + const updateGroupField = (field: string, value: any) => { + const mappings = [...multiMappings]; + mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value }; + onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings); + }; + + return ( +
+ {/* 소스 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + updateGroupField("sourceTable", table.name); + if (!mappingSourceColumnsMap[table.name]) { + const cols = await loadMappingColumns(table.name); + setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols })); + } + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ + {/* 매핑 규칙 목록 */} +
+
+ + +
+ + {!activeSourceTable ? ( +

소스 테이블을 먼저 선택하세요.

+ ) : activeRules.length === 0 ? ( +

매핑 없음 (동일 필드명 자동 매핑)

+ ) : ( + activeRules.map((rule: any, rIdx: number) => { + const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; + const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; + return ( +
+
+ + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open })) + } + > + + + + + + + + 컬럼 없음 + + {activeSourceColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + +
+ + + +
+ + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open })) + } + > + + + + + + + + 컬럼 없음 + + {mappingTargetColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], targetField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + +
+ + +
+ ); + }) + )} +
+
+ ); + })()}
)}
@@ -3567,9 +3712,9 @@ export const ButtonConfigPanel: React.FC = ({
1. 소스 컴포넌트에서 데이터를 선택합니다
- 2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드) + 2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
- 3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다 + 3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다

diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index cb0ca751..6242cd89 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -217,6 +217,7 @@ export const V2PropertiesPanel: React.FC = ({ "v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel, "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel, "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]; @@ -236,11 +237,13 @@ export const V2PropertiesPanel: React.FC = ({ const extraProps: Record = {}; if (componentId === "v2-select") { extraProps.inputType = inputType; + extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; } if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; } - if (componentId === "v2-bom-item-editor") { + if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") { extraProps.currentTableName = currentTableName; extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; } diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 3be70840..d6ed8c62 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -72,9 +72,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, allColumns = response.data; } - // category 타입 컬럼만 필터링 + // category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외) 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("✅ 카테고리 컬럼 필터링 완료:", { diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index d2b288ff..1853ebe7 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -23,6 +23,9 @@ import { import { apiClient } from "@/lib/api/client"; import { allocateNumberingCode } from "@/lib/api/numberingRule"; 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 컴포넌트 재사용 import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable"; @@ -38,6 +41,7 @@ declare global { export const V2Repeater: React.FC = ({ config: propConfig, + componentId, parentId, data: initialData, onDataChange, @@ -48,6 +52,12 @@ export const V2Repeater: React.FC = ({ }) => { // ScreenModal에서 전달된 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( () => ({ @@ -65,9 +75,119 @@ export const V2Repeater: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [modalOpen, setModalOpen] = useState(false); + // 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + // 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용) + const loadedIdsRef = useRef>(new Set()); + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 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 = {}; + 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(); + 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; + 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>({}); @@ -76,6 +196,10 @@ export const V2Repeater: React.FC = ({ // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + const categoryLabelMapRef = useRef>({}); + useEffect(() => { + categoryLabelMapRef.current = categoryLabelMap; + }, [categoryLabelMap]); // 현재 테이블 컬럼 정보 (inputType 매핑용) const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); @@ -109,35 +233,54 @@ export const V2Repeater: React.FC = ({ }; }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); - // 저장 이벤트 리스너 + // 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조) useEffect(() => { const handleSaveEvent = async (event: CustomEvent) => { - // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용 - const tableName = - config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - const eventParentId = event.detail?.parentId; - const mainFormData = event.detail?.mainFormData; + const currentData = dataRef.current; + const currentCategoryMap = categoryLabelMapRef.current; - // 🆕 마스터 테이블에서 생성된 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; - 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; } - // V2Repeater 저장 시작 - const saveInfo = { + if (config.foreignKeyColumn) { + 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, - useCustomTable: config.useCustomTable, - mainTableName: config.mainTableName, foreignKeyColumn: config.foreignKeyColumn, masterRecordId, - dataLength: data.length, - }; - console.log("V2Repeater 저장 시작", saveInfo); + dataLength: currentData.length, + }); try { - // 테이블 유효 컬럼 조회 let validColumns: Set = new Set(); try { const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); @@ -148,13 +291,10 @@ export const V2Repeater: React.FC = ({ console.warn("테이블 컬럼 정보 조회 실패"); } - for (let i = 0; i < data.length; i++) { - const row = data[i]; - - // 내부 필드 제거 + for (let i = 0; i < currentData.length; i++) { + const row = currentData[i]; const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); - // 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함) let mergedData: Record; if (config.useCustomTable && config.mainTableName) { mergedData = { ...cleanRow }; @@ -181,59 +321,83 @@ export const V2Repeater: React.FC = ({ }; } - // 유효하지 않은 컬럼 제거 const filteredData: Record = {}; for (const [key, value] of Object.entries(mergedData)) { 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; + console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, { + rowId, + isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"), + filteredDataKeys: Object.keys(filteredData), + }); if (rowId && typeof rowId === "string" && rowId.includes("-")) { - // UUID 형태의 id가 있으면 기존 데이터 → UPDATE const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData; await apiClient.put(`/table-management/tables/${tableName}/edit`, { originalData: { id: rowId }, updatedData: updateFields, }); } else { - // 새 행 → INSERT 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) { console.error("❌ V2Repeater 저장 실패:", error); - throw error; + toast.error(`V2Repeater 저장 실패: ${error}`); + } finally { + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); } }; - // V2 EventBus 구독 const unsubscribe = v2EventBus.subscribe( V2_EVENTS.REPEATER_SAVE, async (payload) => { - const tableName = + const configTableName = config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - if (payload.tableName === tableName) { + if (!configTableName || payload.tableName === configTableName) { 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); return () => { unsubscribe(); window.removeEventListener("repeaterSave" as any, handleSaveEvent); }; }, [ - data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, + config.foreignKeySourceColumn, parentId, ]); @@ -301,7 +465,6 @@ export const V2Repeater: React.FC = ({ }); // 각 행에 소스 테이블의 표시 데이터 병합 - // RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함 rows.forEach((row: any) => { const sourceRecord = sourceMap.get(String(row[fkColumn])); if (sourceRecord) { @@ -319,12 +482,50 @@ export const V2Repeater: React.FC = ({ } } + // DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환 + const codesToResolve = new Set(); + 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; + 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); dataLoadedRef.current = true; if (onDataChange) onDataChange(rows); } } catch (error) { - console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error); + console.error("[V2Repeater] 기존 데이터 로드 실패:", error); } }; @@ -346,16 +547,28 @@ export const V2Repeater: React.FC = ({ if (!tableName) return; try { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - const columns = response.data?.data?.columns || response.data?.columns || response.data || []; + const [colResponse, typeResponse] = await Promise.all([ + 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 = {}; + inputTypes.forEach((t: any) => { + typeMap[t.columnName] = t; + }); const columnMap: Record = {}; columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; + const typeInfo = typeMap[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, detailSettings: col.detailSettings || col.detail_settings, + categoryRef: typeInfo?.categoryRef || null, }; }); setCurrentTableColumnInfo(columnMap); @@ -487,14 +700,18 @@ export const V2Repeater: React.FC = ({ else if (inputType === "code") type = "select"; else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 - // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) - // category 타입인 경우 현재 테이블명과 컬럼명을 조합 + // 카테고리 참조 ID 결정 + // DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용 let categoryRef: string | undefined; if (inputType === "category") { - // 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용 - const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; - if (tableName) { - categoryRef = `${tableName}.${col.key}`; + const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef; + if (dbCategoryRef) { + categoryRef = dbCategoryRef; + } else { + const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; + if (tableName) { + categoryRef = `${tableName}.${col.key}`; + } } } @@ -512,55 +729,79 @@ export const V2Repeater: React.FC = ({ }); }, [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(() => { - const loadCategoryLabels = async () => { - if (sourceCategoryColumns.length === 0 || data.length === 0) { - return; - } + if (!parentFormData) return; + const codes: string[] = []; - // 데이터에서 카테고리 컬럼의 모든 고유 코드 수집 - const allCodes = new Set(); - for (const row of data) { - for (const col of sourceCategoryColumns) { - // _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인 - const val = row[`_display_${col}`] || row[col]; - 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); - } - } - } + // fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집 + for (const col of config.columns) { + if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) { + const val = parentFormData[col.autoFill.sourceField]; + if (typeof val === "string" && val && !categoryLabelMap[val]) { + codes.push(val); } } - - if (allCodes.size === 0) { - return; - } - - try { - 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, - })); + // receiveFromParent 패턴 + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.key; + const val = parentFormData[parentField]; + if (typeof val === "string" && val && !categoryLabelMap[val]) { + codes.push(val); } - } catch (error) { - console.error("카테고리 라벨 조회 실패:", error); } - }; + } - loadCategoryLabels(); - }, [data, sourceCategoryColumns]); + if (codes.length > 0) { + fetchCategoryLabels(codes); + } + }, [parentFormData, config.columns, fetchCategoryLabels]); + + // 데이터 변경 시 카테고리 라벨 로드 + useEffect(() => { + if (data.length === 0) return; + + const allCodes = new Set(); + + 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_* 필드도 참조 가능) const applyCalculationRules = useCallback( @@ -677,7 +918,12 @@ export const V2Repeater: React.FC = ({ case "fromMainForm": 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 ""; @@ -697,7 +943,7 @@ export const V2Repeater: React.FC = ({ return undefined; } }, - [], + [categoryLabelMap], ); // 🆕 채번 API 호출 (비동기) @@ -731,7 +977,12 @@ export const V2Repeater: React.FC = ({ const row: any = { _id: `grouped_${Date.now()}_${index}` }; 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) { row[col.key] = sourceValue ?? ""; @@ -752,6 +1003,48 @@ export const V2Repeater: React.FC = ({ return row; }); + // 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관) + const categoryColSet = new Set(allCategoryColumns); + const codesToResolve = new Set(); + 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; + 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); onDataChange?.(newRows); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -786,7 +1079,7 @@ export const V2Repeater: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentFormData, config.columns, generateAutoFillValueSync]); - // 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 + // 행 추가 (inline 모드 또는 모달 열기) const handleAddRow = useCallback(async () => { if (isModalMode) { setModalOpen(true); @@ -794,11 +1087,10 @@ export const V2Repeater: React.FC = ({ const newRow: any = { _id: `new_${Date.now()}` }; const currentRowCount = data.length; - // 먼저 동기적 자동 입력 값 적용 + // 동기적 자동 입력 값 적용 for (const col of config.columns) { const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData); if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) { - // 채번 규칙: 즉시 API 호출 newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId); } else if (autoValue !== undefined) { newRow[col.key] = autoValue; @@ -807,10 +1099,51 @@ export const V2Repeater: React.FC = ({ } } + // 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; + 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]; handleDataChange(newData); } - }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]); + }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]); // 모달에서 항목 선택 - 비동기로 변경 const handleSelectItems = useCallback( @@ -835,8 +1168,12 @@ export const V2Repeater: React.FC = ({ // 모든 컬럼 처리 (순서대로) for (const col of config.columns) { if (col.isSourceDisplay) { - // 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용) - row[`_display_${col.key}`] = item[col.key] || ""; + let displayVal = item[col.key] || ""; + // 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관) + if (typeof displayVal === "string" && categoryLabelMap[displayVal]) { + displayVal = categoryLabelMap[displayVal]; + } + row[`_display_${col.key}`] = displayVal; } else { // 자동 입력 값 적용 const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData); @@ -856,6 +1193,43 @@ export const V2Repeater: React.FC = ({ }), ); + // 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환 + const categoryColSet = new Set(allCategoryColumns); + const unresolvedCodes = new Set(); + 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; + 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]; handleDataChange(newData); setModalOpen(false); @@ -869,6 +1243,8 @@ export const V2Repeater: React.FC = ({ generateAutoFillValueSync, generateNumberingCode, parentFormData, + categoryLabelMap, + allCategoryColumns, ], ); @@ -881,9 +1257,6 @@ export const V2Repeater: React.FC = ({ }, [config.columns]); // 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환 - const dataRef = useRef(data); - dataRef.current = data; - useEffect(() => { const handleBeforeFormSave = async (event: Event) => { const customEvent = event as CustomEvent; @@ -1112,7 +1485,7 @@ export const V2Repeater: React.FC = ({ selectedRows={selectedRows} onSelectionChange={setSelectedRows} equalizeWidthsTrigger={autoWidthTrigger} - categoryColumns={sourceCategoryColumns} + categoryColumns={allCategoryColumns} categoryLabelMap={categoryLabelMap} />
diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 4fd27cb0..fe21b790 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -622,6 +622,7 @@ export const V2Select = forwardRef( config: configProp, value, onChange, + onFormDataChange, tableName, columnName, isDesignMode, // 🔧 디자인 모드 (클릭 방지) @@ -630,6 +631,9 @@ export const V2Select = forwardRef( // config가 없으면 기본값 사용 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(config.options || []); const [loading, setLoading] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false); @@ -742,10 +746,7 @@ export const V2Select = forwardRef( const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; const response = await apiClient.get(`/entity/${entityTable}/options`, { - params: { - value: valueCol, - label: labelCol, - }, + params: { value: valueCol, label: labelCol }, }); const data = response.data; if (data.success && data.data) { @@ -819,6 +820,70 @@ export const V2Select = forwardRef( loadOptions(); }, [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 = () => { if (loading) { @@ -876,12 +941,12 @@ export const V2Select = forwardRef( switch (config.mode) { case "dropdown": - case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운 + case "combobox": return ( ( onChange?.(v)} + onChange={(v) => handleChangeWithAutoFill(v)} disabled={isDisabled} /> ); case "check": - case "checkbox": // 🔧 기존 저장된 값 호환 + case "checkbox": return ( @@ -919,7 +984,7 @@ export const V2Select = forwardRef( @@ -930,7 +995,7 @@ export const V2Select = forwardRef( ( onChange?.(v)} + onChange={(v) => handleChangeWithAutoFill(v)} disabled={isDisabled} /> ); @@ -953,7 +1018,7 @@ export const V2Select = forwardRef( @@ -964,7 +1029,7 @@ export const V2Select = forwardRef( diff --git a/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx new file mode 100644 index 00000000..7c8c3ed1 --- /dev/null +++ b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx @@ -0,0 +1,1074 @@ +"use client"; + +/** + * BOM 트리 뷰 설정 패널 + * + * V2BomItemEditorConfigPanel 구조 기반: + * - 기본 탭: 디테일 테이블 + 엔티티 선택 + 트리 설정 + * - 컬럼 탭: 소스 표시 컬럼 + 디테일 컬럼 + 선택된 컬럼 상세 + */ + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Database, + Link2, + Trash2, + GripVertical, + ArrowRight, + ChevronDown, + ChevronRight, + Eye, + EyeOff, + Check, + ChevronsUpDown, + GitBranch, +} from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; + +interface TableRelation { + tableName: string; + tableLabel: string; + foreignKeyColumn: string; + referenceColumn: string; +} + +interface ColumnOption { + columnName: string; + displayName: string; + inputType?: string; + detailSettings?: { + codeGroup?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + format?: string; + }; +} + +interface EntityColumnOption { + columnName: string; + displayName: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; +} + +interface TreeColumnConfig { + key: string; + title: string; + width?: string; + visible?: boolean; + hidden?: boolean; + isSourceDisplay?: boolean; +} + +interface BomTreeConfig { + detailTable?: string; + foreignKey?: string; + parentKey?: string; + + historyTable?: string; + versionTable?: string; + + dataSource?: { + sourceTable?: string; + foreignKey?: string; + referenceKey?: string; + displayColumn?: string; + }; + + columns: TreeColumnConfig[]; + + features?: { + showExpandAll?: boolean; + showHeader?: boolean; + showQuantity?: boolean; + showLossRate?: boolean; + showHistory?: boolean; + showVersion?: boolean; + }; +} + +interface V2BomTreeConfigPanelProps { + config: BomTreeConfig; + onChange: (config: BomTreeConfig) => void; + currentTableName?: string; + screenTableName?: string; +} + +export function V2BomTreeConfigPanel({ + config: propConfig, + onChange, + currentTableName: propCurrentTableName, + screenTableName, +}: V2BomTreeConfigPanelProps) { + const currentTableName = screenTableName || propCurrentTableName; + + const config: BomTreeConfig = useMemo( + () => ({ + columns: [], + ...propConfig, + dataSource: { ...propConfig?.dataSource }, + features: { + showExpandAll: true, + showHeader: true, + showQuantity: true, + showLossRate: true, + ...propConfig?.features, + }, + }), + [propConfig], + ); + + const [detailTableColumns, setDetailTableColumns] = useState([]); + const [entityColumns, setEntityColumns] = useState([]); + const [sourceTableColumns, setSourceTableColumns] = useState([]); + const [allTables, setAllTables] = useState<{ tableName: string; displayName: string }[]>([]); + const [relatedTables, setRelatedTables] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingRelations, setLoadingRelations] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [expandedColumn, setExpandedColumn] = useState(null); + + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange], + ); + + const updateFeatures = useCallback( + (field: string, value: any) => { + updateConfig({ features: { ...config.features, [field]: value } }); + }, + [config.features, updateConfig], + ); + + // 전체 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.table_label || t.tableName || t.table_name, + })), + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 연관 테이블 로드 + useEffect(() => { + const loadRelatedTables = async () => { + const baseTable = currentTableName; + if (!baseTable) { + setRelatedTables([]); + return; + } + setLoadingRelations(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get( + `/table-management/columns/${baseTable}/referenced-by`, + ); + if (response.data.success && response.data.data) { + setRelatedTables( + response.data.data.map((rel: any) => ({ + tableName: rel.tableName || rel.table_name, + tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name, + foreignKeyColumn: rel.columnName || rel.column_name, + referenceColumn: rel.referenceColumn || rel.reference_column || "id", + })), + ); + } + } catch (error) { + console.error("연관 테이블 로드 실패:", error); + setRelatedTables([]); + } finally { + setLoadingRelations(false); + } + }; + loadRelatedTables(); + }, [currentTableName]); + + // 디테일 테이블 선택 + const handleDetailTableSelect = useCallback( + (tableName: string) => { + const relation = relatedTables.find((r) => r.tableName === tableName); + updateConfig({ + detailTable: tableName, + foreignKey: relation?.foreignKeyColumn || config.foreignKey, + }); + }, + [relatedTables, config.foreignKey, updateConfig], + ); + + // 디테일 테이블 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!config.detailTable) { + setDetailTableColumns([]); + setEntityColumns([]); + return; + } + setLoadingColumns(true); + try { + const columnData = await tableTypeApi.getColumns(config.detailTable); + const cols: ColumnOption[] = []; + const entityCols: EntityColumnOption[] = []; + + for (const c of columnData) { + let detailSettings: any = null; + if (c.detailSettings) { + try { + detailSettings = + typeof c.detailSettings === "string" ? JSON.parse(c.detailSettings) : c.detailSettings; + } catch { + // ignore + } + } + + const col: ColumnOption = { + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + detailSettings: detailSettings + ? { + codeGroup: detailSettings.codeGroup, + referenceTable: detailSettings.referenceTable, + referenceColumn: detailSettings.referenceColumn, + displayColumn: detailSettings.displayColumn, + format: detailSettings.format, + } + : undefined, + }; + cols.push(col); + + if (col.inputType === "entity") { + const refTable = detailSettings?.referenceTable || c.referenceTable; + if (refTable) { + entityCols.push({ + columnName: col.columnName, + displayName: col.displayName, + referenceTable: refTable, + referenceColumn: detailSettings?.referenceColumn || c.referenceColumn || "id", + displayColumn: detailSettings?.displayColumn || c.displayColumn, + }); + } + } + } + + setDetailTableColumns(cols); + setEntityColumns(entityCols); + } catch (error) { + console.error("컬럼 로드 실패:", error); + setDetailTableColumns([]); + setEntityColumns([]); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [config.detailTable]); + + // 소스(엔티티) 테이블 컬럼 로드 + useEffect(() => { + const loadSourceColumns = async () => { + const sourceTable = config.dataSource?.sourceTable; + if (!sourceTable) { + setSourceTableColumns([]); + return; + } + setLoadingSourceColumns(true); + try { + const columnData = await tableTypeApi.getColumns(sourceTable); + setSourceTableColumns( + columnData.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + })), + ); + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + setSourceTableColumns([]); + } finally { + setLoadingSourceColumns(false); + } + }; + loadSourceColumns(); + }, [config.dataSource?.sourceTable]); + + // 엔티티 컬럼 선택 시 소스 테이블 자동 설정 + const handleEntityColumnSelect = (columnName: string) => { + const selectedEntity = entityColumns.find((c) => c.columnName === columnName); + if (selectedEntity) { + updateConfig({ + dataSource: { + ...config.dataSource, + sourceTable: selectedEntity.referenceTable || "", + foreignKey: selectedEntity.columnName, + referenceKey: selectedEntity.referenceColumn || "id", + displayColumn: selectedEntity.displayColumn, + }, + }); + } + }; + + // 컬럼 토글 + const toggleDetailColumn = (column: ColumnOption) => { + const exists = config.columns.findIndex((c) => c.key === column.columnName && !c.isSourceDisplay); + if (exists >= 0) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName || c.isSourceDisplay) }); + } else { + const newCol: TreeColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const toggleSourceDisplayColumn = (column: ColumnOption) => { + const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay); + if (exists) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) }); + } else { + const newCol: TreeColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + isSourceDisplay: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const isColumnAdded = (columnName: string) => + config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); + + const isSourceColumnSelected = (columnName: string) => + config.columns.some((c) => c.key === columnName && c.isSourceDisplay); + + const updateColumnProp = (key: string, field: keyof TreeColumnConfig, value: any) => { + updateConfig({ + columns: config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col)), + }); + }; + + // FK/시스템 컬럼 제외한 표시 가능 컬럼 + const displayableColumns = useMemo(() => { + const fkColumn = config.dataSource?.foreignKey; + const systemCols = ["id", "created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"]; + return detailTableColumns.filter( + (col) => col.columnName !== fkColumn && col.inputType !== "entity" && !systemCols.includes(col.columnName), + ); + }, [detailTableColumns, config.dataSource?.foreignKey]); + + // FK 후보 컬럼 + const fkCandidateColumns = useMemo(() => { + const systemCols = ["created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"]; + return detailTableColumns.filter((c) => !systemCols.includes(c.columnName)); + }, [detailTableColumns]); + + return ( +
+ + + + 기본 + + + 컬럼 + + + + {/* ─── 기본 설정 탭 ─── */} + + {/* 디테일 테이블 */} +
+ + +
+
+ +
+

+ {config.detailTable + ? allTables.find((t) => t.tableName === config.detailTable)?.displayName || config.detailTable + : "미설정"} +

+ {config.detailTable && config.foreignKey && ( +

+ FK: {config.foreignKey} → {currentTableName || "메인 테이블"}.id +

+ )} +
+
+
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {relatedTables.length > 0 && ( + + {relatedTables.map((rel) => ( + { + handleDetailTableSelect(rel.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {rel.tableLabel} + + ({rel.foreignKeyColumn}) + + + ))} + + )} + + + {allTables + .filter((t) => !relatedTables.some((r) => r.tableName === t.tableName)) + .map((table) => ( + { + handleDetailTableSelect(table.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + +
+ + + + {/* 트리 구조 설정 */} +
+
+ + +
+

+ 메인 FK와 부모-자식 계층 FK를 선택하세요 +

+ + {fkCandidateColumns.length > 0 ? ( +
+
+ + +
+
+ + +
+
+ ) : ( +
+

+ {loadingColumns ? "로딩 중..." : "디테일 테이블을 먼저 선택하세요"} +

+
+ )} +
+ + + + {/* 엔티티 선택 (품목 참조) */} +
+ +

+ 트리 노드에 표시할 품목 정보의 소스 엔티티 +

+ + {entityColumns.length > 0 ? ( + + ) : ( +
+

+ {loadingColumns + ? "로딩 중..." + : !config.detailTable + ? "디테일 테이블을 먼저 선택하세요" + : "엔티티 타입 컬럼이 없습니다"} +

+
+ )} + + {config.dataSource?.sourceTable && ( +
+

선택된 엔티티

+
+

참조 테이블: {config.dataSource.sourceTable}

+

FK 컬럼: {config.dataSource.foreignKey}

+
+
+ )} +
+ + + + {/* 이력/버전 테이블 설정 */} +
+ +

+ BOM 변경 이력과 버전 관리에 사용할 테이블을 선택하세요 +

+ +
+
+
+ updateFeatures("showHistory", !!checked)} + /> + +
+ {(config.features?.showHistory ?? true) && ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {allTables.map((table) => ( + updateConfig({ historyTable: table.tableName })} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + )} +
+ +
+
+ updateFeatures("showVersion", !!checked)} + /> + +
+ {(config.features?.showVersion ?? true) && ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {allTables.map((table) => ( + updateConfig({ versionTable: table.tableName })} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + )} +
+
+
+ + + + {/* 표시 옵션 */} +
+ +
+
+ updateFeatures("showExpandAll", !!checked)} + /> + +
+
+ updateFeatures("showHeader", !!checked)} + /> + +
+
+ updateFeatures("showQuantity", !!checked)} + /> + +
+
+ updateFeatures("showLossRate", !!checked)} + /> + +
+
+
+ + {/* 메인 화면 테이블 참고 */} + {currentTableName && ( + <> + +
+ +
+

{currentTableName}

+

+ 컬럼 {detailTableColumns.length}개 / 엔티티 {entityColumns.length}개 +

+
+
+ + )} +
+ + {/* ─── 컬럼 설정 탭 ─── */} + +
+ +

+ 트리 노드에 표시할 소스/디테일 컬럼을 선택하세요 +

+ + {/* 소스 테이블 컬럼 (표시용) */} + {config.dataSource?.sourceTable && ( + <> +
+ + 소스 테이블 ({config.dataSource.sourceTable}) - 표시용 +
+ {loadingSourceColumns ? ( +

로딩 중...

+ ) : sourceTableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {sourceTableColumns.map((column) => ( +
toggleSourceDisplayColumn(column)} + > + toggleSourceDisplayColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + 표시 +
+ ))} +
+ )} + + )} + + {/* 디테일 테이블 컬럼 */} +
+ + 디테일 테이블 ({config.detailTable || "미선택"}) - 직접 컬럼 +
+ {loadingColumns ? ( +

로딩 중...

+ ) : displayableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {displayableColumns.map((column) => ( +
toggleDetailColumn(column)} + > + toggleDetailColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + {column.inputType} +
+ ))} +
+ )} +
+ + {/* 선택된 컬럼 상세 */} + {config.columns.length > 0 && ( + <> + +
+ +
+ {config.columns.map((col, index) => ( +
+
e.dataTransfer.setData("columnIndex", String(index))} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10); + if (fromIndex !== index) { + const newColumns = [...config.columns]; + const [movedCol] = newColumns.splice(fromIndex, 1); + newColumns.splice(index, 0, movedCol); + updateConfig({ columns: newColumns }); + } + }} + > + + + {!col.isSourceDisplay && ( + + )} + + {col.isSourceDisplay ? ( + + ) : ( + + )} + + updateColumnProp(col.key, "title", e.target.value)} + placeholder="제목" + className="h-6 flex-1 text-xs" + /> + + {!col.isSourceDisplay && ( + + )} + + +
+ + {/* 확장 상세 */} + {!col.isSourceDisplay && expandedColumn === col.key && ( +
+
+ + updateColumnProp(col.key, "width", e.target.value)} + placeholder="auto, 100px, 20%" + className="h-6 text-xs" + /> +
+
+ )} +
+ ))} +
+
+ + )} +
+
+
+ ); +} + +V2BomTreeConfigPanel.displayName = "V2BomTreeConfigPanel"; + +export default V2BomTreeConfigPanel; diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 5b5b5fc2..1f89ae12 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -1214,13 +1214,21 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} - {/* 편집 가능 체크박스 */} + {/* 편집 가능 토글 */} {!col.isSourceDisplay && ( - updateColumnProp(col.key, "editable", !!checked)} - title="편집 가능" - /> + )} + + )} ); @@ -227,6 +279,10 @@ interface TreeNodeRowProps { onFieldChange: (tempId: string, field: string, value: string) => void; onDelete: (tempId: 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({ @@ -241,6 +297,10 @@ function TreeNodeRow({ onFieldChange, onDelete, onAddChild, + onDragStart, + onDragOver, + onDrop, + isDragOver, }: TreeNodeRowProps) { const indentPx = depth * 32; 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", "transition-colors hover:bg-accent/30", depth > 0 && "ml-2 border-l-2 border-l-primary/20", + isDragOver && "border-primary bg-primary/5 border-dashed", )} style={{ marginLeft: `${indentPx}px` }} + draggable + onDragStart={(e) => onDragStart(e, node.tempId)} + onDragOver={(e) => onDragOver(e, node.tempId)} + onDrop={(e) => onDrop(e, node.tempId)} > @@ -409,7 +474,7 @@ export function BomItemEditorComponent({ // 설정값 추출 const cfg = useMemo(() => component?.componentConfig || {}, [component]); 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 visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]); const fkColumn = cfg.foreignKeyColumn || "bom_id"; @@ -422,6 +487,28 @@ export function BomItemEditorComponent({ return null; }, [propBomId, formData, selectedRowsData]); + // BOM 전용 API로 현재 current_version_id 조회 + const fetchCurrentVersionId = useCallback(async (id: string): Promise => { + 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(() => { @@ -431,7 +518,14 @@ export function BomItemEditorComponent({ for (const col of categoryColumns) { const categoryRef = `${mainTableName}.${col.key}`; - if (categoryOptionsMap[categoryRef]) continue; + + const alreadyLoaded = await new Promise((resolve) => { + setCategoryOptionsMap((prev) => { + resolve(!!prev[categoryRef]); + return prev; + }); + }); + if (alreadyLoaded) continue; try { 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( async (id: string) => { if (!id) return; setLoading(true); try { - const result = await entityJoinApi.getTableDataWithJoins(mainTableName, { - page: 1, - size: 500, - search: { [fkColumn]: id }, - sortBy: "seq_no", - sortOrder: "asc", - enableEntityJoin: true, + // isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청 + const displayCols = columns.filter((c) => c.isSourceDisplay); + const additionalJoinColumns = displayCols.map((col) => ({ + sourceTable, + sourceColumn: sourceFk, + joinAlias: `${sourceFk}_${col.key}`, + referenceTable: sourceTable, + })); + + // 서버에서 최신 current_version_id 조회 (항상 최신 보장) + const freshVersionId = await fetchCurrentVersionId(id); + const effectiveVersionId = freshVersionId || propsVersionId; + + const searchFilter: Record = { [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) => { + 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); setTreeData(tree); @@ -483,14 +614,20 @@ export function BomItemEditorComponent({ setLoading(false); } }, - [mainTableName, fkColumn], + [mainTableName, fkColumn, sourceFk, sourceTable, columns, fetchCurrentVersionId, propsVersionId], ); + // formData.current_version_id가 변경될 때도 재로드 (버전 전환 시 반영) + const formVersionRef = useRef(null); 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); } - }, [bomId, isDesignMode, loadBomDetails]); + }, [bomId, isDesignMode, loadBomDetails, formData?.current_version_id]); // ─── 트리 빌드 (동적 데이터) ─── @@ -548,10 +685,13 @@ export function BomItemEditorComponent({ id: node.id, tempId: node.tempId, [parentKeyColumn]: parentId, + [fkColumn]: bomId, seq_no: String(idx + 1), level: String(level), _isNew: node._isNew, _targetTable: mainTableName, + _fkColumn: fkColumn, + _deferSave: true, }); if (node.children.length > 0) { traverse(node.children, node.id || node.tempId, level + 1); @@ -560,7 +700,7 @@ export function BomItemEditorComponent({ }; traverse(nodes, null, 0); return result; - }, [parentKeyColumn, mainTableName]); + }, [parentKeyColumn, mainTableName, fkColumn, bomId]); // 트리 변경 시 부모에게 알림 const notifyChange = useCallback( @@ -571,6 +711,164 @@ export function BomItemEditorComponent({ [onChange, flattenTree], ); + // ─── DB 저장 (INSERT/UPDATE/DELETE 일괄) ─── + + const [saving, setSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + const originalDataRef = React.useRef>(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) | 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 = {}; + let savedCount = 0; + + for (const { node, parentRealId, level, seqNo } of allNodes) { + const realParentId = parentRealId + ? tempToReal[parentRealId] || parentRealId + : null; + + if (node._isNew) { + const payload: Record = { + ...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 = { + ...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, data: { ...node.data, [field]: value }, })); - notifyChange(newTree); + notifyChangeWithDirty(newTree); }, - [treeData, notifyChange], + [treeData, notifyChangeWithDirty], ); // 노드 삭제 const handleDelete = useCallback( (tempId: string) => { const newTree = findAndUpdate(treeData, tempId, () => null); - notifyChange(newTree); + notifyChangeWithDirty(newTree); }, - [treeData, notifyChange], + [treeData, notifyChangeWithDirty], ); // 하위 품목 추가 시작 (모달 열기) @@ -627,59 +925,62 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); - // 품목 선택 후 추가 (동적 데이터) + // 품목 선택 후 추가 (다중 선택 지원) const handleItemSelect = useCallback( - (item: ItemInfo) => { - // 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식) - const sourceData: Record = {}; - const sourceTable = cfg.dataSource?.sourceTable; - if (sourceTable) { - const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; - sourceData[sourceFk] = item.id; - // 소스 표시 컬럼의 데이터 병합 - Object.keys(item).forEach((key) => { - sourceData[`_display_${key}`] = (item as any)[key]; - sourceData[key] = (item as any)[key]; - }); + (selectedItemsList: ItemInfo[]) => { + let newTree = [...treeData]; + + for (const item of selectedItemsList) { + const sourceData: Record = {}; + const sourceTable = cfg.dataSource?.sourceTable; + if (sourceTable) { + const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; + sourceData[sourceFk] = item.id; + Object.keys(item).forEach((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 = { - 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], - }; - }); + if (addTargetParentId !== null) { 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(null); + const [dragOverId, setDragOverId] = useState(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) => { @@ -711,6 +1107,10 @@ export function BomItemEditorComponent({ onFieldChange={handleFieldChange} onDelete={handleDelete} onAddChild={handleAddChild} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + isDragOver={dragOverId === node.tempId} /> {isExpanded && node.children.length > 0 && @@ -886,19 +1286,33 @@ export function BomItemEditorComponent({
{/* 헤더 */}
-

하위 품목 구성

- +

+ 하위 품목 구성 + {hasChanges && (미저장)} +

+
+ + +
{/* 트리 목록 */} -
+
{loading ? (
로딩 중... diff --git a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx new file mode 100644 index 00000000..cfff4a0c --- /dev/null +++ b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx @@ -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 | null; + isRootNode?: boolean; + tableName: string; + onSaved?: () => void; +} + +export function BomDetailEditModal({ + open, + onOpenChange, + node, + isRootNode = false, + tableName, + onSaved, +}: BomDetailEditModalProps) { + const [formData, setFormData] = useState>({}); + 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 ( + + + + + {isRootNode ? "BOM 헤더 수정" : "품목 수정"} + + + {isRootNode + ? "BOM 기본 정보를 수정합니다" + : "선택한 품목의 BOM 구성 정보를 수정합니다"} + + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + handleChange(isRootNode ? "base_qty" : "quantity", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + handleChange("unit", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {!isRootNode && ( + <> +
+
+ + handleChange("process_type", e.target.value)} + placeholder="예: 조립공정" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + handleChange("loss_rate", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ + )} + +
+ +