From 623ade4f28fdd4376791bcd0b74d8c8b34b6b346 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 21 Jan 2026 17:51:59 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=ED=82=A4=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카테고리 트리 컨트롤러에 전체 카테고리 키 목록 조회 라우트 추가: GET /api/category-tree/test/all-category-keys - 카테고리 트리 서비스에 전체 카테고리 키 목록 조회 메서드 구현: 모든 테이블과 컬럼 조합을 반환 - 채번규칙 컨트롤러에서 폼 데이터 처리 기능 추가: 코드 미리보기 시 카테고리 기반 폼 데이터 사용 - 관련 API 클라이언트 및 타입 정의 업데이트: 카테고리 키 조회 및 채번규칙 API에 대한 요청 처리 개선 이로 인해 카테고리 관리 및 채번규칙 테스트의 효율성이 향상되었습니다. --- .../src/controllers/categoryTreeController.ts | 25 + .../controllers/numberingRuleController.ts | 89 ++- .../src/services/categoryTreeService.ts | 33 + .../src/services/numberingRuleService.ts | 412 +++++++++++- .../src/services/tableCategoryValueService.ts | 43 +- .../numbering-rule/AutoConfigPanel.tsx | 625 +++++++++++++++++- .../numbering-rule/ManualConfigPanel.tsx | 19 +- .../numbering-rule/NumberingRuleCard.tsx | 1 + .../numbering-rule/NumberingRuleDesigner.tsx | 118 +++- frontend/components/unified/UnifiedInput.tsx | 213 ++++-- frontend/components/unified/UnifiedSelect.tsx | 40 +- frontend/lib/api/categoryTree.ts | 16 + frontend/lib/api/numberingRule.ts | 79 ++- frontend/types/numbering-rule.ts | 26 +- 14 files changed, 1601 insertions(+), 138 deletions(-) diff --git a/backend-node/src/controllers/categoryTreeController.ts b/backend-node/src/controllers/categoryTreeController.ts index 8f76bbc7..7f3154ba 100644 --- a/backend-node/src/controllers/categoryTreeController.ts +++ b/backend-node/src/controllers/categoryTreeController.ts @@ -16,6 +16,31 @@ interface AuthenticatedRequest extends Request { }; } +/** + * 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합) + * GET /api/category-tree/test/all-category-keys + * 주의: 이 라우트는 /test/:tableName/:columnName 보다 먼저 정의되어야 함 + */ +router.get("/test/all-category-keys", async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + + const keys = await categoryTreeService.getAllCategoryKeys(companyCode); + + res.json({ + success: true, + data: keys, + }); + } catch (error: unknown) { + const err = error as Error; + logger.error("전체 카테고리 키 목록 조회 API 오류", { error: err.message }); + res.status(500).json({ + success: false, + error: err.message, + }); + } +}); + /** * 카테고리 트리 조회 * GET /api/category-tree/test/:tableName/:columnName diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 2df7b7dc..83cfc53d 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -202,9 +202,10 @@ router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, r router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; + const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용) try { - const previewCode = await numberingRuleService.previewCode(ruleId, companyCode); + const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData); return res.json({ success: true, data: { generatedCode: previewCode } }); } catch (error: any) { logger.error("코드 미리보기 실패", { error: error.message }); @@ -321,4 +322,90 @@ router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, r } }); +// [테스트] 테스트 테이블에서 채번규칙 삭제 +router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + await numberingRuleService.deleteRuleFromTest(ruleId, companyCode); + return res.json({ success: true }); + } catch (error: any) { + logger.error("테스트 테이블에서 채번규칙 삭제 실패", { + error: error.message, + companyCode, + ruleId, + }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// [테스트] 카테고리 조건 포함 채번규칙 조회 +router.get("/test/by-column-with-category", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { tableName, columnName, categoryColumn, categoryValueId } = req.query; + + try { + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ success: false, error: "tableName is required" }); + } + if (!columnName || typeof columnName !== "string") { + return res.status(400).json({ success: false, error: "columnName is required" }); + } + + const rule = await numberingRuleService.getNumberingRuleByColumnWithCategory( + companyCode, + tableName, + columnName, + categoryColumn as string | undefined, + categoryValueId ? Number(categoryValueId) : undefined + ); + + if (!rule) { + return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" }); + } + + return res.json({ success: true, data: rule }); + } catch (error: any) { + logger.error("카테고리 조건 포함 채번규칙 조회 실패", { + error: error.message, + companyCode, + tableName, + columnName, + }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// [테스트] 테이블.컬럼별 모든 채번규칙 조회 (카테고리 조건별) +router.get("/test/rules-by-table-column", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.query; + + try { + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ success: false, error: "tableName is required" }); + } + if (!columnName || typeof columnName !== "string") { + return res.status(400).json({ success: false, error: "columnName is required" }); + } + + const rules = await numberingRuleService.getRulesByTableColumn( + companyCode, + tableName, + columnName + ); + + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("테이블.컬럼별 채번규칙 목록 조회 실패", { + error: error.message, + companyCode, + tableName, + columnName, + }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + export default router; diff --git a/backend-node/src/services/categoryTreeService.ts b/backend-node/src/services/categoryTreeService.ts index 591d7873..6cb725c9 100644 --- a/backend-node/src/services/categoryTreeService.ts +++ b/backend-node/src/services/categoryTreeService.ts @@ -507,6 +507,39 @@ class CategoryTreeService { throw error; } } + + /** + * 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합) + * category_values_test 테이블에서 고유한 table_name, column_name 조합을 조회 + * 라벨 정보도 함께 반환 + */ + async getAllCategoryKeys(companyCode: string): Promise<{ tableName: string; columnName: string; tableLabel: string; columnLabel: string }[]> { + logger.info("getAllCategoryKeys 호출", { companyCode }); + const pool = getPool(); + + try { + const query = ` + SELECT DISTINCT + cv.table_name AS "tableName", + cv.column_name AS "columnName", + COALESCE(tl.table_label, cv.table_name) AS "tableLabel", + COALESCE(cl.column_label, cv.column_name) AS "columnLabel" + FROM category_values_test cv + LEFT JOIN table_labels tl ON tl.table_name = cv.table_name + LEFT JOIN column_labels cl ON cl.table_name = cv.table_name AND cl.column_name = cv.column_name + WHERE cv.company_code = $1 OR cv.company_code = '*' + ORDER BY cv.table_name, cv.column_name + `; + + const result = await pool.query(query, [companyCode]); + logger.info("전체 카테고리 키 목록 조회 완료", { count: result.rows.length }); + return result.rows; + } catch (error: unknown) { + const err = error as Error; + logger.error("전체 카테고리 키 목록 조회 실패", { error: err.message }); + throw error; + } + } } export const categoryTreeService = new CategoryTreeService(); diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index a0964703..ad1b672e 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -29,6 +29,10 @@ interface NumberingRuleConfig { companyCode?: string; menuObjid?: number; scopeType?: string; + // 카테고리 조건 + categoryColumn?: string; + categoryValueId?: number; + categoryValueLabel?: string; // 조회 시 조인해서 가져옴 createdAt?: string; updatedAt?: string; createdBy?: string; @@ -882,8 +886,15 @@ class NumberingRuleService { /** * 코드 미리보기 (순번 증가 없음) + * @param ruleId 채번 규칙 ID + * @param companyCode 회사 코드 + * @param formData 폼 데이터 (카테고리 기반 채번 시 사용) */ - async previewCode(ruleId: string, companyCode: string): Promise { + async previewCode( + ruleId: string, + companyCode: string, + formData?: Record + ): Promise { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); @@ -891,7 +902,8 @@ class NumberingRuleService { .sort((a: any, b: any) => a.order - b.order) .map((part: any) => { if (part.generationMethod === "manual") { - return part.manualConfig?.value || ""; + // 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력) + return part.manualConfig?.placeholder || "____"; } const autoConfig = part.autoConfig || {}; @@ -913,10 +925,23 @@ class NumberingRuleService { case "date": { // 날짜 (다양한 날짜 형식) - return this.formatDate( - new Date(), - autoConfig.dateFormat || "YYYYMMDD" - ); + const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; + + // 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출 + if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) { + const columnValue = formData[autoConfig.sourceColumnName]; + if (columnValue) { + const dateValue = columnValue instanceof Date + ? columnValue + : new Date(columnValue); + + if (!isNaN(dateValue.getTime())) { + return this.formatDate(dateValue, dateFormat); + } + } + } + + return this.formatDate(new Date(), dateFormat); } case "text": { @@ -924,6 +949,71 @@ class NumberingRuleService { return autoConfig.textValue || "TEXT"; } + case "category": { + // 카테고리 기반 코드 생성 + const categoryKey = autoConfig.categoryKey; // 예: "item_info.material" + const categoryMappings = autoConfig.categoryMappings || []; + + if (!categoryKey || !formData) { + logger.warn("카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData }); + return ""; + } + + // categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material") + const columnName = categoryKey.includes(".") + ? categoryKey.split(".")[1] + : categoryKey; + + // 폼 데이터에서 해당 컬럼의 값 가져오기 + const selectedValue = formData[columnName]; + + logger.info("카테고리 파트 처리", { + categoryKey, + columnName, + selectedValue, + formDataKeys: Object.keys(formData), + mappingsCount: categoryMappings.length + }); + + if (!selectedValue) { + logger.warn("카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) }); + return ""; + } + + // 카테고리 매핑에서 해당 값에 대한 형식 찾기 + // selectedValue는 valueCode일 수 있음 (UnifiedSelect에서 valueCode를 value로 사용) + const selectedValueStr = String(selectedValue); + const mapping = categoryMappings.find( + (m: any) => { + // ID로 매칭 + if (m.categoryValueId?.toString() === selectedValueStr) return true; + // 라벨로 매칭 + if (m.categoryValueLabel === selectedValueStr) return true; + // valueCode로 매칭 (라벨과 동일할 수 있음) + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + } + ); + + if (mapping) { + logger.info("카테고리 매핑 적용", { + selectedValue, + format: mapping.format, + categoryValueLabel: mapping.categoryValueLabel + }); + return mapping.format || ""; + } + + logger.warn("카테고리 매핑을 찾을 수 없음", { + selectedValue, + availableMappings: categoryMappings.map((m: any) => ({ + id: m.categoryValueId, + label: m.categoryValueLabel + })) + }); + return ""; + } + default: logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; @@ -931,7 +1021,7 @@ class NumberingRuleService { }); const previewCode = parts.join(rule.separator || ""); - logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode }); + logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData }); return previewCode; } @@ -1119,22 +1209,27 @@ class NumberingRuleService { const pool = getPool(); const query = ` SELECT - rule_id AS "ruleId", - rule_name AS "ruleName", - description, - separator, - reset_period AS "resetPeriod", - current_sequence AS "currentSequence", - table_name AS "tableName", - column_name AS "columnName", - company_code AS "companyCode", - created_at AS "createdAt", - updated_at AS "updatedAt", - created_by AS "createdBy" - FROM numbering_rules_test - WHERE company_code = $1 - AND table_name = $2 - AND column_name = $3 + r.rule_id AS "ruleId", + r.rule_name AS "ruleName", + r.description, + r.separator, + r.reset_period AS "resetPeriod", + r.current_sequence AS "currentSequence", + r.table_name AS "tableName", + r.column_name AS "columnName", + r.company_code AS "companyCode", + r.category_column AS "categoryColumn", + r.category_value_id AS "categoryValueId", + cv.value_label AS "categoryValueLabel", + r.created_at AS "createdAt", + r.updated_at AS "updatedAt", + r.created_by AS "createdBy" + FROM numbering_rules_test r + LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id + WHERE r.company_code = $1 + AND r.table_name = $2 + AND r.column_name = $3 + AND r.category_value_id IS NULL LIMIT 1 `; const params = [companyCode, tableName, columnName]; @@ -1225,8 +1320,10 @@ class NumberingRuleService { reset_period = $4, table_name = $5, column_name = $6, + category_column = $7, + category_value_id = $8, updated_at = NOW() - WHERE rule_id = $7 AND company_code = $8 + WHERE rule_id = $9 AND company_code = $10 `; await client.query(updateQuery, [ config.ruleName, @@ -1235,6 +1332,8 @@ class NumberingRuleService { config.resetPeriod || "none", config.tableName || "", config.columnName || "", + config.categoryColumn || null, + config.categoryValueId || null, config.ruleId, companyCode, ]); @@ -1250,8 +1349,9 @@ class NumberingRuleService { INSERT INTO numbering_rules_test ( rule_id, rule_name, description, separator, reset_period, current_sequence, table_name, column_name, company_code, + category_column, category_value_id, created_at, updated_at, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW(), $12) `; await client.query(insertQuery, [ config.ruleId, @@ -1263,6 +1363,8 @@ class NumberingRuleService { config.tableName || "", config.columnName || "", companyCode, + config.categoryColumn || null, + config.categoryValueId || null, createdBy, ]); } @@ -1309,6 +1411,266 @@ class NumberingRuleService { client.release(); } } + + /** + * [테스트] 테스트 테이블에서 채번규칙 삭제 + * numbering_rules_test 테이블 사용 + */ + async deleteRuleFromTest(ruleId: string, companyCode: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { ruleId, companyCode }); + + // 파트 먼저 삭제 + await client.query( + "DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + + // 규칙 삭제 + const result = await client.query( + "DELETE FROM numbering_rules_test WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + + await client.query("COMMIT"); + + logger.info("테스트 테이블에서 채번 규칙 삭제 완료", { + ruleId, + companyCode, + deletedCount: result.rowCount, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("테스트 테이블에서 채번 규칙 삭제 실패", { + error: error.message, + ruleId, + companyCode, + }); + throw error; + } finally { + client.release(); + } + } + + /** + * [테스트] 카테고리 값에 따라 적절한 채번규칙 조회 + * 1. 해당 카테고리 값에 매칭되는 규칙 찾기 + * 2. 없으면 기본 규칙(category_value_id가 NULL인) 찾기 + */ + async getNumberingRuleByColumnWithCategory( + companyCode: string, + tableName: string, + columnName: string, + categoryColumn?: string, + categoryValueId?: number + ): Promise { + try { + logger.info("카테고리 조건 포함 채번 규칙 조회 시작", { + companyCode, + tableName, + columnName, + categoryColumn, + categoryValueId, + }); + + const pool = getPool(); + + // 1. 카테고리 값에 매칭되는 규칙 찾기 + if (categoryColumn && categoryValueId) { + const categoryQuery = ` + SELECT + r.rule_id AS "ruleId", + r.rule_name AS "ruleName", + r.description, + r.separator, + r.reset_period AS "resetPeriod", + r.current_sequence AS "currentSequence", + r.table_name AS "tableName", + r.column_name AS "columnName", + r.company_code AS "companyCode", + r.category_column AS "categoryColumn", + r.category_value_id AS "categoryValueId", + cv.value_label AS "categoryValueLabel", + r.created_at AS "createdAt", + r.updated_at AS "updatedAt", + r.created_by AS "createdBy" + FROM numbering_rules_test r + LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id + WHERE r.company_code = $1 + AND r.table_name = $2 + AND r.column_name = $3 + AND r.category_column = $4 + AND r.category_value_id = $5 + LIMIT 1 + `; + const categoryResult = await pool.query(categoryQuery, [ + companyCode, + tableName, + columnName, + categoryColumn, + categoryValueId, + ]); + + if (categoryResult.rows.length > 0) { + const rule = categoryResult.rows[0]; + // 파트 정보 조회 + const partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts_test + WHERE rule_id = $1 AND company_code = $2 + ORDER BY part_order + `; + const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + rule.parts = partsResult.rows; + + logger.info("카테고리 조건 매칭 채번 규칙 찾음", { + ruleId: rule.ruleId, + categoryValueLabel: rule.categoryValueLabel, + }); + return rule; + } + } + + // 2. 기본 규칙 찾기 (category_value_id가 NULL인) + const defaultQuery = ` + SELECT + r.rule_id AS "ruleId", + r.rule_name AS "ruleName", + r.description, + r.separator, + r.reset_period AS "resetPeriod", + r.current_sequence AS "currentSequence", + r.table_name AS "tableName", + r.column_name AS "columnName", + r.company_code AS "companyCode", + r.category_column AS "categoryColumn", + r.category_value_id AS "categoryValueId", + r.created_at AS "createdAt", + r.updated_at AS "updatedAt", + r.created_by AS "createdBy" + FROM numbering_rules_test r + WHERE r.company_code = $1 + AND r.table_name = $2 + AND r.column_name = $3 + AND r.category_value_id IS NULL + LIMIT 1 + `; + const defaultResult = await pool.query(defaultQuery, [companyCode, tableName, columnName]); + + if (defaultResult.rows.length > 0) { + const rule = defaultResult.rows[0]; + // 파트 정보 조회 + const partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts_test + WHERE rule_id = $1 AND company_code = $2 + ORDER BY part_order + `; + const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + rule.parts = partsResult.rows; + + logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { + ruleId: rule.ruleId, + }); + return rule; + } + + logger.info("채번 규칙을 찾을 수 없음", { + companyCode, + tableName, + columnName, + categoryColumn, + categoryValueId, + }); + return null; + } catch (error: any) { + logger.error("카테고리 조건 포함 채번 규칙 조회 실패", { + error: error.message, + stack: error.stack, + }); + throw error; + } + } + + /** + * [테스트] 특정 테이블.컬럼의 모든 채번 규칙 조회 (카테고리 조건별) + */ + async getRulesByTableColumn( + companyCode: string, + tableName: string, + columnName: string + ): Promise { + try { + const pool = getPool(); + const query = ` + SELECT + r.rule_id AS "ruleId", + r.rule_name AS "ruleName", + r.description, + r.separator, + r.reset_period AS "resetPeriod", + r.current_sequence AS "currentSequence", + r.table_name AS "tableName", + r.column_name AS "columnName", + r.company_code AS "companyCode", + r.category_column AS "categoryColumn", + r.category_value_id AS "categoryValueId", + cv.value_label AS "categoryValueLabel", + r.created_at AS "createdAt", + r.updated_at AS "updatedAt", + r.created_by AS "createdBy" + FROM numbering_rules_test r + LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id + WHERE r.company_code = $1 + AND r.table_name = $2 + AND r.column_name = $3 + ORDER BY r.category_value_id NULLS FIRST, r.created_at + `; + const result = await pool.query(query, [companyCode, tableName, columnName]); + + // 각 규칙의 파트 정보 조회 + for (const rule of result.rows) { + const partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts_test + WHERE rule_id = $1 AND company_code = $2 + ORDER BY part_order + `; + const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + rule.parts = partsResult.rows; + } + + return result.rows; + } catch (error: any) { + logger.error("테이블.컬럼별 채번 규칙 목록 조회 실패", { + error: error.message, + }); + throw error; + } + } } export const numberingRuleService = new NumberingRuleService(); diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 9cbbc521..bb7ee28a 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -207,48 +207,27 @@ class TableCategoryValueService { is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", - menu_objid AS "menuObjid", + NULL::numeric AS "menuObjid", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy", updated_by AS "updatedBy" - FROM table_column_category_values + FROM category_values_test WHERE table_name = $1 AND column_name = $2 `; + // category_values_test 테이블 사용 (menu_objid 없음) if (companyCode === "*") { - // 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회 - if (menuObjid && siblingObjids.length > 0) { - query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`; - params = [tableName, columnName, siblingObjids]; - logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids }); - } else if (menuObjid) { - query = baseSelect + ` AND menu_objid = $3`; - params = [tableName, columnName, menuObjid]; - logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid }); - } else { - // menuObjid 없으면 모든 값 조회 (중복 가능) - query = baseSelect; - params = [tableName, columnName]; - logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)"); - } + // 최고 관리자: 모든 값 조회 + query = baseSelect; + params = [tableName, columnName]; + logger.info("최고 관리자 전체 카테고리 값 조회 (category_values_test)"); } else { - // 일반 회사: 자신의 회사 + menuObjid로 필터링 - if (menuObjid && siblingObjids.length > 0) { - query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`; - params = [tableName, columnName, companyCode, siblingObjids]; - logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids }); - } else if (menuObjid) { - query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`; - params = [tableName, columnName, companyCode, menuObjid]; - logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid }); - } else { - // menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한) - query = baseSelect + ` AND company_code = $3`; - params = [tableName, columnName, companyCode]; - logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode }); - } + // 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회 + query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`; + params = [tableName, columnName, companyCode]; + logger.info("회사별 카테고리 값 조회 (category_values_test)", { companyCode }); } if (!includeInactive) { diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx index 5fd28ed5..a902327f 100644 --- a/frontend/components/numbering-rule/AutoConfigPanel.tsx +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -150,6 +150,17 @@ export const AutoConfigPanel: React.FC = ({ ); } + // 5. 카테고리 + if (partType === "category") { + return ( + + ); + } + return null; }; @@ -463,3 +474,615 @@ const DateConfigPanel: React.FC = ({ ); }; + +/** + * 카테고리 타입 전용 설정 패널 + * - 카테고리 선택 (테이블.컬럼) + * - 카테고리 값별 형식 매핑 + */ +import { CategoryFormatMapping } from "@/types/numbering-rule"; +import { Plus, Trash2, FolderTree } from "lucide-react"; +import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree"; + +interface CategoryValueNode { + valueId: number; + valueCode: string; + valueLabel: string; + depth: number; + path: string; + parentValueId: number | null; + children?: CategoryValueNode[]; +} + +interface CategoryConfigPanelProps { + config?: { + categoryKey?: string; + categoryMappings?: CategoryFormatMapping[]; + }; + onChange: (config: any) => void; + isPreview?: boolean; +} + +const CategoryConfigPanel: React.FC = ({ + config = {}, + onChange, + isPreview = false, +}) => { + // 카테고리 옵션 (테이블.컬럼 + 라벨) + const [categoryOptions, setCategoryOptions] = useState<{ + tableName: string; + columnName: string; + displayName: string; + displayLabel: string; // 라벨 (테이블라벨.컬럼라벨) + }[]>([]); + const [categoryKeyOpen, setCategoryKeyOpen] = useState(false); + + // 카테고리 값 트리 + const [categoryValues, setCategoryValues] = useState([]); + const [loadingValues, setLoadingValues] = useState(false); + + // 계층적 선택 상태 (대분류, 중분류, 소분류) + const [level1Id, setLevel1Id] = useState(null); + const [level2Id, setLevel2Id] = useState(null); + const [level3Id, setLevel3Id] = useState(null); + const [level1Open, setLevel1Open] = useState(false); + const [level2Open, setLevel2Open] = useState(false); + const [level3Open, setLevel3Open] = useState(false); + + // 형식 입력 + const [newFormat, setNewFormat] = useState(""); + + // 수정 모드 + const [editingId, setEditingId] = useState(null); + + // 수정 모드 진입 중 플래그 (useEffect 초기화 방지) + const isEditingRef = useRef(false); + + const categoryKey = config.categoryKey || ""; + const mappings = config.categoryMappings || []; + + // 이미 추가된 카테고리 ID 목록 (수정 중인 항목 제외) + const addedValueIds = useMemo(() => { + return mappings + .filter(m => m.categoryValueId !== editingId) + .map(m => m.categoryValueId); + }, [mappings, editingId]); + + // 카테고리 옵션 로드 + useEffect(() => { + loadCategoryOptions(); + }, []); + + // 카테고리 키 변경 시 값 로드 및 선택 초기화 + useEffect(() => { + if (categoryKey) { + const [tableName, columnName] = categoryKey.split("."); + if (tableName && columnName) { + loadCategoryValues(tableName, columnName); + } + } else { + setCategoryValues([]); + } + // 선택 초기화 + setLevel1Id(null); + setLevel2Id(null); + setLevel3Id(null); + }, [categoryKey]); + + // 대분류 변경 시 중분류/소분류 초기화 (수정 모드 진입 중에는 건너뜀) + useEffect(() => { + if (isEditingRef.current) return; + setLevel2Id(null); + setLevel3Id(null); + }, [level1Id]); + + // 중분류 변경 시 소분류 초기화 (수정 모드 진입 중에는 건너뜀) + useEffect(() => { + if (isEditingRef.current) return; + setLevel3Id(null); + }, [level2Id]); + + const loadCategoryOptions = async () => { + try { + const response = await getAllCategoryKeys(); + if (response.success && response.data) { + const options = response.data.map((item: { tableName: string; columnName: string; tableLabel?: string; columnLabel?: string }) => ({ + tableName: item.tableName, + columnName: item.columnName, + displayName: `${item.tableName}.${item.columnName}`, + displayLabel: `${item.tableLabel || item.tableName}.${item.columnLabel || item.columnName}`, + })); + setCategoryOptions(options); + } + } catch (error) { + console.error("카테고리 옵션 로드 실패:", error); + } + }; + + const loadCategoryValues = async (tableName: string, columnName: string) => { + console.log("loadCategoryValues 호출:", { tableName, columnName }); + setLoadingValues(true); + try { + const response = await getCategoryTree(tableName, columnName); + console.log("getCategoryTree 응답:", response); + if (response.success && response.data) { + console.log("카테고리 트리 로드 성공:", response.data); + setCategoryValues(response.data); + } else { + console.log("카테고리 트리 로드 실패:", response.error); + setCategoryValues([]); + } + } catch (error) { + console.error("카테고리 값 로드 실패:", error); + setCategoryValues([]); + } finally { + setLoadingValues(false); + } + }; + + // 이미 추가된 항목 확인 (해당 노드 또는 하위 노드가 추가되었는지) + const isNodeOrDescendantAdded = useCallback((node: CategoryValueNode): boolean => { + if (addedValueIds.includes(node.valueId)) return true; + if (node.children?.length) { + return node.children.every(child => isNodeOrDescendantAdded(child)); + } + return false; + }, [addedValueIds]); + + // 각 레벨별 항목 계산 (이미 추가된 항목 필터링) + const level1Items = useMemo(() => { + return categoryValues.filter(v => !isNodeOrDescendantAdded(v)); + }, [categoryValues, isNodeOrDescendantAdded]); + + const level2Items = useMemo(() => { + if (!level1Id) return []; + const parent = categoryValues.find(v => v.valueId === level1Id); + const children = parent?.children || []; + return children.filter(v => !isNodeOrDescendantAdded(v)); + }, [categoryValues, level1Id, isNodeOrDescendantAdded]); + + const level3Items = useMemo(() => { + if (!level2Id) return []; + const parent = categoryValues.find(v => v.valueId === level1Id); + const level2Parent = parent?.children?.find(v => v.valueId === level2Id); + const children = level2Parent?.children || []; + return children.filter(v => !addedValueIds.includes(v.valueId)); + }, [categoryValues, level1Id, level2Id, addedValueIds]); + + // 선택된 값 정보 계산 + const getSelectedInfo = () => { + // 가장 깊은 레벨의 선택된 값 + const selectedId = level3Id || level2Id || level1Id; + if (!selectedId) return null; + + // 선택된 노드 찾기 + const findNode = (nodes: CategoryValueNode[], id: number): CategoryValueNode | null => { + for (const node of nodes) { + if (node.valueId === id) return node; + if (node.children?.length) { + const found = findNode(node.children, id); + if (found) return found; + } + } + return null; + }; + + const node = findNode(categoryValues, selectedId); + if (!node) return null; + + // 경로 생성 + const pathParts: string[] = []; + const l1 = categoryValues.find(v => v.valueId === level1Id); + if (l1) pathParts.push(l1.valueLabel); + if (level2Id) { + const l2 = level2Items.find(v => v.valueId === level2Id); + if (l2) pathParts.push(l2.valueLabel); + } + if (level3Id) { + const l3 = level3Items.find(v => v.valueId === level3Id); + if (l3) pathParts.push(l3.valueLabel); + } + + return { + valueId: selectedId, + valueLabel: node.valueLabel, + valuePath: pathParts.join(" > "), + }; + }; + + const selectedInfo = getSelectedInfo(); + + // 매핑 추가/수정 + const handleAddMapping = () => { + if (!selectedInfo || !newFormat.trim()) return; + + const newMapping: CategoryFormatMapping = { + categoryValueId: selectedInfo.valueId, + categoryValueLabel: selectedInfo.valueLabel, + categoryValuePath: selectedInfo.valuePath, + format: newFormat.trim(), + }; + + let updatedMappings: CategoryFormatMapping[]; + + if (editingId !== null) { + // 수정 모드: 기존 항목 교체 + updatedMappings = mappings.map(m => + m.categoryValueId === editingId ? newMapping : m + ); + } else { + // 추가 모드: 중복 체크 + const exists = mappings.some(m => m.categoryValueId === selectedInfo.valueId); + if (exists) { + alert("이미 추가된 카테고리입니다"); + return; + } + updatedMappings = [...mappings, newMapping]; + } + + onChange({ + ...config, + categoryMappings: updatedMappings, + }); + + // 초기화 + setLevel1Id(null); + setLevel2Id(null); + setLevel3Id(null); + setNewFormat(""); + setEditingId(null); + }; + + // 매핑 수정 모드 진입 + const handleEditMapping = (mapping: CategoryFormatMapping) => { + // useEffect 초기화 방지 플래그 설정 + isEditingRef.current = true; + + // 해당 카테고리의 경로를 파싱해서 레벨별로 설정 + const findParentIds = (nodes: CategoryValueNode[], targetId: number, path: number[] = []): number[] | null => { + for (const node of nodes) { + if (node.valueId === targetId) { + return path; + } + if (node.children?.length) { + const result = findParentIds(node.children, targetId, [...path, node.valueId]); + if (result) return result; + } + } + return null; + }; + + const parentPath = findParentIds(categoryValues, mapping.categoryValueId); + + if (parentPath && parentPath.length > 0) { + setLevel1Id(parentPath[0] || null); + if (parentPath.length === 2) { + // 3단계: 대분류 > 중분류 > 소분류 + setLevel2Id(parentPath[1]); + setLevel3Id(mapping.categoryValueId); + } else if (parentPath.length === 1) { + // 2단계: 대분류 > 중분류 + setLevel2Id(mapping.categoryValueId); + setLevel3Id(null); + } else { + setLevel2Id(null); + setLevel3Id(null); + } + } else { + // 루트 레벨 항목 (1단계) + setLevel1Id(mapping.categoryValueId); + setLevel2Id(null); + setLevel3Id(null); + } + + setNewFormat(mapping.format); + setEditingId(mapping.categoryValueId); + + // 다음 렌더링 사이클에서 플래그 해제 + setTimeout(() => { + isEditingRef.current = false; + }, 0); + }; + + // 수정 취소 + const handleCancelEdit = () => { + setLevel1Id(null); + setLevel2Id(null); + setLevel3Id(null); + setNewFormat(""); + setEditingId(null); + }; + + // 매핑 삭제 + const handleRemoveMapping = (valueId: number) => { + onChange({ + ...config, + categoryMappings: mappings.filter(m => m.categoryValueId !== valueId), + }); + }; + + return ( +
+ {/* 카테고리 선택 */} +
+ + + + + + + + + + 카테고리가 없습니다 + + {categoryOptions.map((opt) => ( + { + onChange({ ...config, categoryKey: opt.displayName, categoryMappings: [] }); + setCategoryKeyOpen(false); + }} + className="text-xs sm:text-sm" + > + + {opt.displayLabel} + + ))} + + + + + +
+ + {/* 형식 설정 */} + {categoryKey && ( +
+ + + {/* 계층적 선택 UI */} +
+
+ {/* 대분류 선택 */} +
+ + + + + + + + + + 항목이 없습니다 + + {level1Items.map((val) => ( + { + setLevel1Id(val.valueId); + setLevel1Open(false); + }} + className="text-xs" + > + + {val.valueLabel} + + ))} + + + + + +
+ + {/* 중분류 선택 (대분류 선택 후 하위가 있을 때만 표시) */} + {level1Id && level2Items.length > 0 && ( +
+ + + + + + + + + + 항목이 없습니다 + + {level2Items.map((val) => ( + { + setLevel2Id(val.valueId); + setLevel2Open(false); + }} + className="text-xs" + > + + {val.valueLabel} + + ))} + + + + + +
+ )} + + {/* 소분류 선택 (중분류 선택 후 하위가 있을 때만 표시) */} + {level2Id && level3Items.length > 0 && ( +
+ + + + + + + + + + 항목이 없습니다 + + {level3Items.map((val) => ( + { + setLevel3Id(val.valueId); + setLevel3Open(false); + }} + className="text-xs" + > + + {val.valueLabel} + + ))} + + + + + +
+ )} +
+ + {/* 형식 입력 + 추가/수정 버튼 */} +
+
+ + setNewFormat(e.target.value.toUpperCase())} + placeholder="예: ITM, VLV, PIP" + disabled={isPreview || !selectedInfo} + className="h-8 text-xs" + maxLength={10} + /> +
+
+ {editingId !== null && ( + + )} + +
+
+ + {/* 선택된 경로 표시 */} + {selectedInfo && ( +

+ {editingId !== null ? "수정 중: " : "선택: "}{selectedInfo.valuePath} +

+ )} +
+ + {/* 추가된 매핑 목록 */} + {mappings.length > 0 && ( +
+ +
+ {mappings.map((m) => ( +
!isPreview && handleEditMapping(m)} + > +
+ {m.categoryValuePath || m.categoryValueLabel} + + {m.format} +
+ +
+ ))} +
+
+ )} + +

+ 선택된 카테고리 값에 따라 다른 형식이 생성됩니다 +

+
+ )} +
+ ); +}; diff --git a/frontend/components/numbering-rule/ManualConfigPanel.tsx b/frontend/components/numbering-rule/ManualConfigPanel.tsx index 636b7914..c1b49230 100644 --- a/frontend/components/numbering-rule/ManualConfigPanel.tsx +++ b/frontend/components/numbering-rule/ManualConfigPanel.tsx @@ -9,7 +9,7 @@ interface ManualConfigPanelProps { value?: string; placeholder?: string; }; - onChange: (config: any) => void; + onChange: (config: { value?: string; placeholder?: string }) => void; isPreview?: boolean; } @@ -20,17 +20,9 @@ export const ManualConfigPanel: React.FC = ({ }) => { return (
-
- - onChange({ ...config, value: e.target.value })} - placeholder={config.placeholder || "값을 입력하세요"} - disabled={isPreview} - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -

- 코드 생성 시 이 값이 그대로 사용됩니다 +

+

+ 사용자가 폼에서 직접 입력합니다

@@ -42,6 +34,9 @@ export const ManualConfigPanel: React.FC = ({ disabled={isPreview} className="h-8 text-xs sm:h-10 sm:text-sm" /> +

+ 사용자에게 표시될 안내 문구입니다 +

); diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index 8d362f5d..d1444d4e 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -56,6 +56,7 @@ export const NumberingRuleCard: React.FC = ({ number: { numberLength: 4, numberValue: 1 }, date: { dateFormat: "YYYYMMDD" }, text: { textValue: "CODE" }, + category: { categoryKey: "", categoryMappings: [] }, }; onUpdate({ partType: newPartType, diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 60eda2eb..5cd826d0 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -6,17 +6,31 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Plus, Save, Edit2, Trash2 } from "lucide-react"; +import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react"; import { toast } from "sonner"; import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRulePreview } from "./NumberingRulePreview"; import { getAvailableNumberingRules, - createNumberingRule, - updateNumberingRule, - deleteNumberingRule, + saveNumberingRuleToTest, + deleteNumberingRuleFromTest, } from "@/lib/api/numberingRule"; +import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; + +// 카테고리 값 트리 노드 타입 +interface CategoryValueNode { + valueId: number; + valueCode: string; + valueLabel: string; + depth: number; + path: string; + parentValueId: number | null; + children?: CategoryValueNode[]; +} interface NumberingRuleDesignerProps { initialConfig?: NumberingRuleConfig; @@ -52,10 +66,96 @@ export const NumberingRuleDesigner: React.FC = ({ const [separatorType, setSeparatorType] = useState("-"); const [customSeparator, setCustomSeparator] = useState(""); + // 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회 + interface CategoryOption { + tableName: string; + columnName: string; + displayName: string; // "테이블명.컬럼명" 형식 + } + const [allCategoryOptions, setAllCategoryOptions] = useState([]); + const [selectedCategoryKey, setSelectedCategoryKey] = useState(""); // "tableName.columnName" + const [categoryValues, setCategoryValues] = useState([]); + const [categoryKeyOpen, setCategoryKeyOpen] = useState(false); + const [categoryValueOpen, setCategoryValueOpen] = useState(false); + const [loadingCategories, setLoadingCategories] = useState(false); + useEffect(() => { loadRules(); + loadAllCategoryOptions(); // 전체 카테고리 옵션 로드 }, []); + // currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화 + useEffect(() => { + if (currentRule?.categoryColumn) { + setSelectedCategoryKey(currentRule.categoryColumn); + } else { + setSelectedCategoryKey(""); + } + }, [currentRule?.categoryColumn]); + + // 카테고리 키 선택 시 해당 카테고리 값 로드 + useEffect(() => { + if (selectedCategoryKey) { + const [tableName, columnName] = selectedCategoryKey.split("."); + if (tableName && columnName) { + loadCategoryValues(tableName, columnName); + } + } else { + setCategoryValues([]); + } + }, [selectedCategoryKey]); + + // 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼) + const loadAllCategoryOptions = async () => { + try { + // category_values_test 테이블에서 고유한 테이블.컬럼 조합 조회 + const response = await getAllCategoryKeys(); + if (response.success && response.data) { + const options: CategoryOption[] = response.data.map((item) => ({ + tableName: item.tableName, + columnName: item.columnName, + displayName: `${item.tableName}.${item.columnName}`, + })); + setAllCategoryOptions(options); + console.log("전체 카테고리 옵션 로드:", options); + } + } catch (error) { + console.error("카테고리 옵션 목록 조회 실패:", error); + } + }; + + // 특정 카테고리 컬럼의 값 트리 조회 + const loadCategoryValues = async (tableName: string, columnName: string) => { + setLoadingCategories(true); + try { + const response = await getCategoryTree(tableName, columnName); + if (response.success && response.data) { + setCategoryValues(response.data); + console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length }); + } else { + setCategoryValues([]); + } + } catch (error) { + console.error("카테고리 값 트리 조회 실패:", error); + setCategoryValues([]); + } finally { + setLoadingCategories(false); + } + }; + + // 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용) + const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => { + for (const node of nodes) { + result.push(node); + if (node.children && node.children.length > 0) { + flattenCategoryValues(node.children, result); + } + } + return result; + }; + + const flatCategoryValues = flattenCategoryValues(categoryValues); + const loadRules = useCallback(async () => { setLoading(true); try { @@ -237,12 +337,8 @@ export const NumberingRuleDesigner: React.FC = ({ ruleToSave, }); - let response; - if (existing) { - response = await updateNumberingRule(ruleToSave.ruleId, ruleToSave); - } else { - response = await createNumberingRule(ruleToSave); - } + // 테스트 테이블에 저장 (numbering_rules_test) + const response = await saveNumberingRuleToTest(ruleToSave); if (response.success && response.data) { setSavedRules((prev) => { @@ -278,7 +374,7 @@ export const NumberingRuleDesigner: React.FC = ({ async (ruleId: string) => { setLoading(true); try { - const response = await deleteNumberingRule(ruleId); + const response = await deleteNumberingRuleFromTest(ruleId); if (response.success) { setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId)); diff --git a/frontend/components/unified/UnifiedInput.tsx b/frontend/components/unified/UnifiedInput.tsx index 6638fa1d..0aea0331 100644 --- a/frontend/components/unified/UnifiedInput.tsx +++ b/frontend/components/unified/UnifiedInput.tsx @@ -429,6 +429,35 @@ export const UnifiedInput = forwardRef((props // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoGeneration.enabled, autoGeneration.type, isEditMode, formDataForNumbering]); + // 채번 규칙 ID 캐싱 + const numberingRuleIdRef = useRef(null); + const lastCategoryValuesRef = useRef(""); + // 사용자가 직접 입력 중인지 추적 (재생성 방지) + const userEditedNumberingRef = useRef(false); + // 원래 수동 입력 부분이 있었는지 추적 (____가 있었으면 계속 편집 가능) + const hadManualPartRef = useRef(false); + // 채번 템플릿 저장 (____가 포함된 원본 형태) + const numberingTemplateRef = useRef(""); + // 사용자가 수동 입력한 값 저장 + const [manualInputValue, setManualInputValue] = useState(""); + + // formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시) + // 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지) + const categoryValuesForNumbering = useMemo(() => { + const inputType = config.inputType || config.type || "text"; + if (inputType !== "numbering") return ""; + // formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외) + const categoryFields: Record = {}; + for (const [key, val] of Object.entries(formData)) { + // 현재 채번 필드(columnName)는 제외 + if (key === columnName) continue; + if (typeof val === "string" && val) { + categoryFields[key] = val; + } + } + return JSON.stringify(categoryFields); + }, [config.inputType, config.type, formData, columnName]); + // 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용) useEffect(() => { const generateNumberingCode = async () => { @@ -439,18 +468,29 @@ export const UnifiedInput = forwardRef((props return; } - // 이미 생성되었거나 생성 중이면 스킵 - if (hasGeneratedNumberingRef.current || isGeneratingNumbering) { - return; - } - // 수정 모드에서는 자동생성 안함 if (isEditMode) { return; } - // 이미 값이 있으면 스킵 - if (value !== undefined && value !== null && value !== "") { + // 생성 중이면 스킵 + if (isGeneratingNumbering) { + return; + } + + // 사용자가 직접 편집한 경우 재생성 안함 (단, 카테고리 변경 시에는 재생성) + const categoryChanged = categoryValuesForNumbering !== lastCategoryValuesRef.current; + if (userEditedNumberingRef.current && !categoryChanged) { + return; + } + + // 이미 생성되었고 카테고리 값이 변경되지 않았으면 스킵 + if (hasGeneratedNumberingRef.current && !categoryChanged) { + return; + } + + // 첫 생성 시: 값이 이미 있고 카테고리 변경이 아니면 스킵 + if (!categoryChanged && value !== undefined && value !== null && value !== "") { return; } @@ -463,47 +503,87 @@ export const UnifiedInput = forwardRef((props setIsGeneratingNumbering(true); try { - // 테이블 설정에서 numberingRuleId 조회 - const { getTableColumns } = await import("@/lib/api/tableManagement"); - const columnsResponse = await getTableColumns(tableName); + // 채번 규칙 ID 캐싱 (한 번만 조회) + if (!numberingRuleIdRef.current) { + const { getTableColumns } = await import("@/lib/api/tableManagement"); + const columnsResponse = await getTableColumns(tableName); - if (!columnsResponse.success || !columnsResponse.data) { - console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse); - return; - } + if (!columnsResponse.success || !columnsResponse.data) { + console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse); + return; + } - const columns = columnsResponse.data.columns || columnsResponse.data; - const targetColumn = columns.find((col: any) => col.columnName === columnName); + const columns = columnsResponse.data.columns || columnsResponse.data; + const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName); - if (!targetColumn) { - console.warn("컬럼 정보를 찾을 수 없습니다:", columnName); - return; - } + if (!targetColumn) { + console.warn("컬럼 정보를 찾을 수 없습니다:", columnName); + return; + } - // detailSettings에서 numberingRuleId 추출 - let numberingRuleId: string | undefined; - if (targetColumn.detailSettings && typeof targetColumn.detailSettings === "string") { - try { - const parsed = JSON.parse(targetColumn.detailSettings); - numberingRuleId = parsed.numberingRuleId; - } catch { - // JSON 파싱 실패 + // detailSettings에서 numberingRuleId 추출 + if (targetColumn.detailSettings && typeof targetColumn.detailSettings === "string") { + try { + const parsed = JSON.parse(targetColumn.detailSettings); + numberingRuleIdRef.current = parsed.numberingRuleId || null; + } catch { + // JSON 파싱 실패 + } } } + const numberingRuleId = numberingRuleIdRef.current; + if (!numberingRuleId) { console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName }); return; } - // 채번 코드 생성 (미리보기) - const previewResponse = await previewNumberingCode(numberingRuleId); + // 채번 코드 생성 (formData 전달하여 카테고리 값 기반 생성) + const previewResponse = await previewNumberingCode(numberingRuleId, formData); if (previewResponse.success && previewResponse.data?.generatedCode) { const generatedCode = previewResponse.data.generatedCode; - setAutoGeneratedValue(generatedCode); - onChange?.(generatedCode); hasGeneratedNumberingRef.current = true; + lastCategoryValuesRef.current = categoryValuesForNumbering; + + // 수동 입력 부분이 있는 경우 + if (generatedCode.includes("____")) { + hadManualPartRef.current = true; + const oldTemplate = numberingTemplateRef.current; + numberingTemplateRef.current = generatedCode; + + // 카테고리 변경으로 템플릿이 바뀌었을 때 기존 사용자 입력값 유지 + if (oldTemplate && oldTemplate !== generatedCode) { + // 템플릿이 변경되었지만 사용자 입력값은 유지 + const templateParts = generatedCode.split("____"); + const templatePrefix = templateParts[0] || ""; + const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : ""; + + // 기존 manualInputValue를 사용하여 새 값 조합 (상태는 유지) + // 참고: setManualInputValue는 호출하지 않음 (기존 값 유지) + const finalValue = templatePrefix + (userEditedNumberingRef.current ? "" : "") + templateSuffix; + + // 사용자가 입력한 적이 없으면 템플릿 그대로 + if (!userEditedNumberingRef.current) { + setAutoGeneratedValue(generatedCode); + onChange?.(generatedCode); + } + // 사용자가 입력한 적이 있으면 입력값 유지하며 템플릿만 변경 + // (manualInputValue 상태는 유지되므로 UI에서 자동 반영) + } else { + // 첫 생성 + setAutoGeneratedValue(generatedCode); + onChange?.(generatedCode); + userEditedNumberingRef.current = false; + } + } else { + // 수동 입력 부분 없음 + setAutoGeneratedValue(generatedCode); + onChange?.(generatedCode); + userEditedNumberingRef.current = false; + } + console.log("채번 코드 생성 성공:", generatedCode); } else { console.warn("채번 코드 생성 실패:", previewResponse); @@ -517,7 +597,7 @@ export const UnifiedInput = forwardRef((props generateNumberingCode(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableName, columnName, isEditMode, value]); + }, [tableName, columnName, isEditMode, categoryValuesForNumbering]); // 실제 표시할 값 (자동생성 값 또는 props value) const displayValue = autoGeneratedValue ?? value; @@ -618,17 +698,66 @@ export const UnifiedInput = forwardRef((props /> ); - case "numbering": - // 채번 타입: 읽기 전용 텍스트 필드로 표시 (자동 생성) + case "numbering": { + // 채번 타입: ____ 부분만 편집 가능하게 처리 + const template = numberingTemplateRef.current; + const canEdit = hadManualPartRef.current && template; + + console.log("채번 필드 렌더링:", { displayValue, template, manualInputValue, canEdit, isGeneratingNumbering }); + + // 템플릿이 없으면 읽기 전용 (아직 생성 전이거나 수동 입력 부분 없음) + if (!canEdit) { + return ( + {}} + placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"} + readonly={true} + disabled={disabled || isGeneratingNumbering} + /> + ); + } + + // 템플릿에서 prefix와 suffix 추출 + const templateParts = template.split("____"); + const templatePrefix = templateParts[0] || ""; + const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : ""; + return ( - {}} - placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"} - readonly={true} - disabled={disabled || isGeneratingNumbering} - /> +
+ {/* 고정 접두어 */} + {templatePrefix && ( + + {templatePrefix} + + )} + {/* 편집 가능한 부분 */} + { + const newUserInput = e.target.value; + setManualInputValue(newUserInput); + + // 전체 값 조합 + const newValue = templatePrefix + newUserInput + templateSuffix; + userEditedNumberingRef.current = true; + setAutoGeneratedValue(newValue); + onChange?.(newValue); + }} + placeholder="입력" + className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none" + disabled={disabled || isGeneratingNumbering} + /> + {/* 고정 접미어 */} + {templateSuffix && ( + + {templateSuffix} + + )} +
); + } default: return ( diff --git a/frontend/components/unified/UnifiedSelect.tsx b/frontend/components/unified/UnifiedSelect.tsx index 04c546a7..de1652a4 100644 --- a/frontend/components/unified/UnifiedSelect.tsx +++ b/frontend/components/unified/UnifiedSelect.tsx @@ -454,6 +454,8 @@ export const UnifiedSelect = forwardRef( config: configProp, value, onChange, + tableName, + columnName, } = props; // config가 없으면 기본값 사용 @@ -464,16 +466,13 @@ export const UnifiedSelect = forwardRef( const [optionsLoaded, setOptionsLoaded] = useState(false); // 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용) - // category 소스는 code로 자동 변환 (카테고리 → 공통코드 통합) const rawSource = config.source; const categoryTable = (config as any).categoryTable; const categoryColumn = (config as any).categoryColumn; - // category 소스인 경우 code로 변환하고 codeGroup을 자동 생성 - const source = rawSource === "category" ? "code" : rawSource; - const codeGroup = rawSource === "category" && categoryTable && categoryColumn - ? `${categoryTable.toUpperCase()}_${categoryColumn.toUpperCase()}` - : config.codeGroup; + // category 소스 유지 (category_values_test 테이블에서 로드) + const source = rawSource; + const codeGroup = config.codeGroup; const entityTable = config.entityTable; const entityValueColumn = config.entityValueColumn || config.entityValueField; @@ -590,6 +589,35 @@ export const UnifiedSelect = forwardRef( if (Array.isArray(data)) { fetchedOptions = data; } + } else if (source === "category") { + // 카테고리에서 로드 (category_values_test 테이블) + // tableName, columnName은 props에서 가져옴 + const catTable = categoryTable || tableName; + const catColumn = categoryColumn || columnName; + + if (catTable && catColumn) { + const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`); + const data = response.data; + if (data.success && data.data) { + // 트리 구조를 평탄화하여 옵션으로 변환 + // value로 valueId를 사용하여 채번 규칙 매핑과 일치하도록 함 + const flattenTree = (items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[], depth: number = 0): SelectOption[] => { + const result: SelectOption[] = []; + for (const item of items) { + const prefix = depth > 0 ? " ".repeat(depth) + "└ " : ""; + result.push({ + value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치) + label: prefix + item.valueLabel, + }); + if (item.children && item.children.length > 0) { + result.push(...flattenTree(item.children, depth + 1)); + } + } + return result; + }; + fetchedOptions = flattenTree(data.data); + } + } } setOptions(fetchedOptions); diff --git a/frontend/lib/api/categoryTree.ts b/frontend/lib/api/categoryTree.ts index e3e8e020..720cb99d 100644 --- a/frontend/lib/api/categoryTree.ts +++ b/frontend/lib/api/categoryTree.ts @@ -189,3 +189,19 @@ export async function getCategoryColumns( } } +/** + * 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합) + */ +export async function getAllCategoryKeys(): Promise> { + try { + const response = await apiClient.get("/category-tree/test/all-category-keys"); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "전체 카테고리 키 조회 실패", + }; + } +} + diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts index 3f4d1ecf..983cb490 100644 --- a/frontend/lib/api/numberingRule.ts +++ b/frontend/lib/api/numberingRule.ts @@ -105,9 +105,12 @@ export async function deleteNumberingRule(ruleId: string): Promise ): Promise> { // ruleId 유효성 검사 if (!ruleId || ruleId === "undefined" || ruleId === "null") { @@ -115,16 +118,19 @@ export async function previewNumberingCode( } try { - const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`); + const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, { + formData: formData || {}, + }); if (!response.data) { return { success: false, error: "서버 응답이 비어있습니다" }; } return response.data; - } catch (error: any) { + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string; message?: string } }; message?: string }; const errorMessage = - error.response?.data?.error || - error.response?.data?.message || - error.message || + err.response?.data?.error || + err.response?.data?.message || + err.message || "코드 미리보기 실패"; return { success: false, error: errorMessage }; } @@ -208,3 +214,64 @@ export async function saveNumberingRuleToTest( } } +/** + * [테스트] 테스트 테이블에서 채번규칙 삭제 + * numbering_rules_test 테이블 사용 + */ +export async function deleteNumberingRuleFromTest( + ruleId: string +): Promise> { + try { + const response = await apiClient.delete(`/numbering-rules/test/${ruleId}`); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "테스트 규칙 삭제 실패", + }; + } +} + +/** + * [테스트] 카테고리 조건 포함 채번규칙 조회 + */ +export async function getNumberingRuleByColumnWithCategory( + tableName: string, + columnName: string, + categoryColumn?: string, + categoryValueId?: number +): Promise> { + try { + const response = await apiClient.get("/numbering-rules/test/by-column-with-category", { + params: { tableName, columnName, categoryColumn, categoryValueId }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.error || error.message || "카테고리 조건 규칙 조회 실패", + }; + } +} + +/** + * [테스트] 테이블.컬럼별 모든 채번규칙 조회 (카테고리 조건별) + */ +export async function getRulesByTableColumn( + tableName: string, + columnName: string +): Promise> { + try { + const response = await apiClient.get("/numbering-rules/test/rules-by-table-column", { + params: { tableName, columnName }, + }); + return response.data; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.error || error.message || "테이블.컬럼별 규칙 목록 조회 실패", + }; + } +} + diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts index 4f086b5e..b788814c 100644 --- a/frontend/types/numbering-rule.ts +++ b/frontend/types/numbering-rule.ts @@ -4,13 +4,14 @@ */ /** - * 코드 파트 유형 (4가지) + * 코드 파트 유형 (5가지) */ export type CodePartType = | "sequence" // 순번 (자동 증가 숫자) | "number" // 숫자 (고정 자릿수) | "date" // 날짜 (다양한 날짜 형식) - | "text"; // 문자 (텍스트) + | "text" // 문자 (텍스트) + | "category"; // 카테고리 (카테고리 값에 따른 형식) /** * 생성 방식 @@ -30,6 +31,17 @@ export type DateFormat = | "YYYYMMDD" // 20251104 | "YYMMDD"; // 251104 +/** + * 카테고리 값별 형식 매핑 + * 예: 가스켓 → ITM, 벌브 → VLV + */ +export interface CategoryFormatMapping { + categoryValueId: number; // 카테고리 값 ID + categoryValueLabel: string; // 카테고리 값 라벨 (표시용) + categoryValuePath?: string; // 전체 경로 (예: "원자재/벌크/가스켓") + format: string; // 생성할 형식 (예: "ITM", "VLV") +} + /** * 단일 규칙 파트 */ @@ -57,6 +69,10 @@ export interface NumberingRulePart { // 문자용 textValue?: string; // 텍스트 값 (예: "PRJ", "CODE") + + // 카테고리용 + categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type") + categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑 }; // 직접 입력 설정 @@ -91,6 +107,11 @@ export interface NumberingRuleConfig { tableName?: string; // 적용할 테이블명 columnName?: string; // 적용할 컬럼명 + // 카테고리 조건 (특정 카테고리 값일 때만 이 규칙 적용) + categoryColumn?: string; // 카테고리 조건 컬럼명 (예: 'type', 'material') + categoryValueId?: number; // 카테고리 값 ID (category_values_test.value_id) + categoryValueLabel?: string; // 카테고리 값 라벨 (조회 시 조인) + // 메타 정보 companyCode?: string; createdAt?: string; @@ -106,6 +127,7 @@ export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string; { value: "number", label: "숫자", description: "고정 자릿수 숫자 (001, 002...)" }, { value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" }, { value: "text", label: "문자", description: "텍스트 또는 코드" }, + { value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" }, ]; export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [