From 16cb1ea1af75fa8d4c01e69ecd1f37b2799ecef1 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 21 Jan 2026 13:54:14 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20?= =?UTF-8?q?=EC=B1=84=EB=B2=88=EA=B7=9C=EC=B9=99=20API=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?:=20numberingRuleController=EC=97=90=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94+=EC=BB=AC=EB=9F=BC=20=EA=B8=B0=EB=B0=98=20=EC=B1=84?= =?UTF-8?q?=EB=B2=88=EA=B7=9C=EC=B9=99=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=97=90=20=EC=B1=84=EB=B2=88=EA=B7=9C=EC=B9=99=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=98=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.=20=EC=9D=B4?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=B4=20=EA=B0=9C=EB=B0=9C=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B1=84=EB=B2=88=EA=B7=9C=EC=B9=99=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B4=EB=8B=A4=20=EC=89=BD=EA=B2=8C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=ED=95=98=EC=98=80=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/numberingRuleController.ts | 63 ++++++ .../src/services/numberingRuleService.ts | 210 ++++++++++++++++++ .../admin/systemMng/tableMngList/page.tsx | 177 ++++++++++++++- frontend/components/unified/UnifiedInput.tsx | 110 +++++++++ .../config-panels/UnifiedInputConfigPanel.tsx | 44 +++- frontend/lib/api/numberingRule.ts | 39 ++++ .../lib/registry/DynamicComponentRenderer.tsx | 2 + frontend/types/input-types.ts | 24 +- 8 files changed, 664 insertions(+), 5 deletions(-) diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index f61bd4e4..2df7b7dc 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -258,4 +258,67 @@ router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedReques } }); +// ====== 테스트용 API (menu_objid 없는 방식) ====== + +// [테스트] 테이블+컬럼 기반 채번규칙 조회 +router.get("/test/by-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 rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + tableName, + columnName + ); + + 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.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const config = req.body; + + try { + if (!config.ruleId || !config.ruleName) { + return res.status(400).json({ success: false, error: "ruleId and ruleName are required" }); + } + if (!config.tableName || !config.columnName) { + return res.status(400).json({ success: false, error: "tableName and columnName are required" }); + } + + const savedRule = await numberingRuleService.saveRuleToTest(config, companyCode, userId); + return res.json({ success: true, data: savedRule }); + } catch (error: any) { + logger.error("테스트 테이블에 채번규칙 저장 실패", { + error: error.message, + companyCode, + ruleId: config.ruleId, + }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + export default router; diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index db37d4b5..a0964703 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -1099,6 +1099,216 @@ class NumberingRuleService { ); logger.info("시퀀스 초기화 완료", { ruleId, companyCode }); } + + /** + * [테스트] 테이블명 + 컬럼명 기반으로 채번규칙 조회 (menu_objid 없이) + * numbering_rules_test 테이블 사용 + */ + async getNumberingRuleByColumn( + companyCode: string, + tableName: string, + columnName: string + ): Promise { + try { + logger.info("테이블+컬럼 기반 채번 규칙 조회 시작 (테스트)", { + companyCode, + tableName, + columnName, + }); + + 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 + LIMIT 1 + `; + const params = [companyCode, tableName, columnName]; + + const result = await pool.query(query, params); + + if (result.rows.length === 0) { + logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", { + companyCode, + tableName, + columnName, + }); + return null; + } + + const rule = result.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, + ruleName: rule.ruleName, + }); + return rule; + } catch (error: any) { + logger.error("테이블+컬럼 기반 채번 규칙 조회 실패 (테스트)", { + error: error.message, + stack: error.stack, + companyCode, + tableName, + columnName, + }); + throw error; + } + } + + /** + * [테스트] 테스트 테이블에 채번규칙 저장 + * numbering_rules_test 테이블 사용 + */ + async saveRuleToTest( + config: NumberingRuleConfig, + companyCode: string, + createdBy: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + logger.info("테스트 테이블에 채번 규칙 저장 시작", { + ruleId: config.ruleId, + ruleName: config.ruleName, + tableName: config.tableName, + columnName: config.columnName, + companyCode, + }); + + // 기존 규칙 확인 + const existingQuery = ` + SELECT rule_id FROM numbering_rules_test + WHERE rule_id = $1 AND company_code = $2 + `; + const existingResult = await client.query(existingQuery, [config.ruleId, companyCode]); + + if (existingResult.rows.length > 0) { + // 업데이트 + const updateQuery = ` + UPDATE numbering_rules_test SET + rule_name = $1, + description = $2, + separator = $3, + reset_period = $4, + table_name = $5, + column_name = $6, + updated_at = NOW() + WHERE rule_id = $7 AND company_code = $8 + `; + await client.query(updateQuery, [ + config.ruleName, + config.description || "", + config.separator || "-", + config.resetPeriod || "none", + config.tableName || "", + config.columnName || "", + config.ruleId, + companyCode, + ]); + + // 기존 파트 삭제 + await client.query( + "DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2", + [config.ruleId, companyCode] + ); + } else { + // 신규 등록 + const insertQuery = ` + INSERT INTO numbering_rules_test ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, updated_at, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10) + `; + await client.query(insertQuery, [ + config.ruleId, + config.ruleName, + config.description || "", + config.separator || "-", + config.resetPeriod || "none", + config.currentSequence || 1, + config.tableName || "", + config.columnName || "", + companyCode, + createdBy, + ]); + } + + // 파트 저장 + if (config.parts && config.parts.length > 0) { + for (const part of config.parts) { + const partInsertQuery = ` + INSERT INTO numbering_rule_parts_test ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + `; + await client.query(partInsertQuery, [ + config.ruleId, + part.order, + part.partType, + part.generationMethod, + JSON.stringify(part.autoConfig || {}), + JSON.stringify(part.manualConfig || {}), + companyCode, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("테스트 테이블에 채번 규칙 저장 완료", { + ruleId: config.ruleId, + companyCode, + }); + + return config; + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("테스트 테이블에 채번 규칙 저장 실패", { + error: error.message, + stack: error.stack, + ruleId: config.ruleId, + companyCode, + }); + throw error; + } finally { + client.release(); + } + } } export const numberingRuleService = new NumberingRuleService(); diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 4ba1e6c0..ec78a180 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -21,6 +21,8 @@ import { commonCodeApi } from "@/lib/api/commonCode"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { ddlApi } from "@/lib/api/ddl"; import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue"; +import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; import { CreateTableModal } from "@/components/admin/CreateTableModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; @@ -60,6 +62,7 @@ interface ColumnTypeInfo { displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열 hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할 + numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID } interface SecondLevelMenu { @@ -112,6 +115,11 @@ export default function TableManagementPage() { // 🆕 Category 타입용: 2레벨 메뉴 목록 const [secondLevelMenus, setSecondLevelMenus] = useState([]); + // 🆕 Numbering 타입용: 채번규칙 목록 + const [numberingRules, setNumberingRules] = useState([]); + const [numberingRulesLoading, setNumberingRulesLoading] = useState(false); + const [numberingComboboxOpen, setNumberingComboboxOpen] = useState>({}); + // 로그 뷰어 상태 const [logViewerOpen, setLogViewerOpen] = useState(false); const [logViewerTableName, setLogViewerTableName] = useState(""); @@ -263,6 +271,25 @@ export default function TableManagementPage() { } }; + // 🆕 채번규칙 목록 로드 + const loadNumberingRules = async () => { + setNumberingRulesLoading(true); + try { + const response = await getNumberingRules(); + if (response.success && response.data) { + setNumberingRules(response.data); + } else { + console.warn("⚠️ 채번규칙 로드 실패:", response); + setNumberingRules([]); + } + } catch (error) { + console.error("❌ 채번규칙 로드 에러:", error); + setNumberingRules([]); + } finally { + setNumberingRulesLoading(false); + } + }; + // 테이블 목록 로드 const loadTables = async () => { setLoading(true); @@ -304,14 +331,18 @@ export default function TableManagementPage() { // 컬럼 데이터에 기본값 설정 const processedColumns = (data.columns || data).map((col: any) => { - // detailSettings에서 hierarchyRole 추출 + // detailSettings에서 hierarchyRole, numberingRuleId 추출 let hierarchyRole: "large" | "medium" | "small" | undefined = undefined; + let numberingRuleId: string | undefined = undefined; if (col.detailSettings && typeof col.detailSettings === "string") { try { const parsed = JSON.parse(col.detailSettings); if (parsed.hierarchyRole === "large" || parsed.hierarchyRole === "medium" || parsed.hierarchyRole === "small") { hierarchyRole = parsed.hierarchyRole; } + if (parsed.numberingRuleId) { + numberingRuleId = parsed.numberingRuleId; + } } catch { // JSON 파싱 실패 시 무시 } @@ -320,6 +351,7 @@ export default function TableManagementPage() { return { ...col, inputType: col.inputType || "text", // 기본값: text + numberingRuleId, // 🆕 채번규칙 ID categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 hierarchyRole, // 계층구조 역할 }; @@ -557,6 +589,38 @@ export default function TableManagementPage() { console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings); } + // 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함 + console.log("🔍 Numbering 저장 체크:", { + inputType: column.inputType, + numberingRuleId: column.numberingRuleId, + hasNumberingRuleId: !!column.numberingRuleId, + }); + + if (column.inputType === "numbering") { + let existingSettings: Record = {}; + if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { + try { + existingSettings = JSON.parse(finalDetailSettings); + } catch { + existingSettings = {}; + } + } + + // numberingRuleId가 있으면 저장, 없으면 제거 + if (column.numberingRuleId) { + const numberingSettings = { + ...existingSettings, + numberingRuleId: column.numberingRuleId, + }; + finalDetailSettings = JSON.stringify(numberingSettings); + console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings); + } else { + // numberingRuleId가 없으면 빈 객체 + finalDetailSettings = JSON.stringify(existingSettings); + console.log("🔧 Numbering 규칙 없이 저장:", existingSettings); + } + } + const columnSetting = { columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnLabel: column.displayName, // 사용자가 입력한 표시명 @@ -826,6 +890,7 @@ export default function TableManagementPage() { loadTables(); loadCommonCodeCategories(); loadSecondLevelMenus(); + loadNumberingRules(); }, []); // 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드 @@ -1675,6 +1740,116 @@ export default function TableManagementPage() { )} )} + {/* 입력 타입이 'numbering'인 경우 채번규칙 선택 */} + {column.inputType === "numbering" && ( +
+ + + setNumberingComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: open, + })) + } + > + + + + + + + + + 채번규칙을 찾을 수 없습니다. + + + { + const columnIndex = columns.findIndex((c) => c.columnName === column.columnName); + handleColumnChange(columnIndex, "numberingRuleId", undefined); + setNumberingComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: false, + })); + // 🆕 자동 저장 (선택 해제) + const updatedColumn = { ...column, numberingRuleId: undefined }; + await handleSaveColumn(updatedColumn); + }} + className="text-xs" + > + + -- 선택 안함 -- + + {numberingRules.map((rule) => ( + { + const columnIndex = columns.findIndex((c) => c.columnName === column.columnName); + // 상태 업데이트 + handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId); + setNumberingComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: false, + })); + // 🆕 자동 저장 + const updatedColumn = { ...column, numberingRuleId: rule.ruleId }; + await handleSaveColumn(updatedColumn); + }} + className="text-xs" + > + +
+ {rule.ruleName} + {rule.tableName && ( + + {rule.tableName}.{rule.columnName} + + )} +
+
+ ))} +
+
+
+
+
+ {column.numberingRuleId && ( +
+ + 규칙 설정됨 +
+ )} +
+ )}
diff --git a/frontend/components/unified/UnifiedInput.tsx b/frontend/components/unified/UnifiedInput.tsx index 6e4b9927..6638fa1d 100644 --- a/frontend/components/unified/UnifiedInput.tsx +++ b/frontend/components/unified/UnifiedInput.tsx @@ -20,6 +20,7 @@ import { cn } from "@/lib/utils"; import { UnifiedInputProps, UnifiedInputConfig, UnifiedInputFormat } from "@/types/unified-components"; import { AutoGenerationUtils } from "@/lib/utils/autoGeneration"; import { AutoGenerationConfig } from "@/types/screen"; +import { previewNumberingCode } from "@/lib/api/numberingRule"; // 형식별 입력 마스크 및 검증 패턴 const FORMAT_PATTERNS: Record = { @@ -354,6 +355,13 @@ export const UnifiedInput = forwardRef((props const hasGeneratedRef = useRef(false); const lastFormDataRef = useRef(""); // 마지막 formData 추적 (채번 규칙용) + // 채번 타입 자동생성 상태 + const [isGeneratingNumbering, setIsGeneratingNumbering] = useState(false); + const hasGeneratedNumberingRef = useRef(false); + + // tableName 추출 (props에서 전달받거나 config에서) + const tableName = (props as any).tableName || (config as any).tableName; + // 수정 모드 여부 확인 const originalData = (props as any).originalData || (props as any)._originalData; const isEditMode = originalData && Object.keys(originalData).length > 0; @@ -421,6 +429,96 @@ export const UnifiedInput = forwardRef((props // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoGeneration.enabled, autoGeneration.type, isEditMode, formDataForNumbering]); + // 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용) + useEffect(() => { + const generateNumberingCode = async () => { + const inputType = config.inputType || config.type || "text"; + + // numbering 타입이 아니면 스킵 + if (inputType !== "numbering") { + return; + } + + // 이미 생성되었거나 생성 중이면 스킵 + if (hasGeneratedNumberingRef.current || isGeneratingNumbering) { + return; + } + + // 수정 모드에서는 자동생성 안함 + if (isEditMode) { + return; + } + + // 이미 값이 있으면 스킵 + if (value !== undefined && value !== null && value !== "") { + return; + } + + // tableName과 columnName이 필요 + if (!tableName || !columnName) { + console.warn("채번 타입: tableName 또는 columnName이 없습니다", { tableName, columnName }); + return; + } + + setIsGeneratingNumbering(true); + + try { + // 테이블 설정에서 numberingRuleId 조회 + const { getTableColumns } = await import("@/lib/api/tableManagement"); + const columnsResponse = await getTableColumns(tableName); + + 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); + + 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 파싱 실패 + } + } + + if (!numberingRuleId) { + console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName }); + return; + } + + // 채번 코드 생성 (미리보기) + const previewResponse = await previewNumberingCode(numberingRuleId); + + if (previewResponse.success && previewResponse.data?.generatedCode) { + const generatedCode = previewResponse.data.generatedCode; + setAutoGeneratedValue(generatedCode); + onChange?.(generatedCode); + hasGeneratedNumberingRef.current = true; + console.log("채번 코드 생성 성공:", generatedCode); + } else { + console.warn("채번 코드 생성 실패:", previewResponse); + } + } catch (error) { + console.error("채번 자동생성 오류:", error); + } finally { + setIsGeneratingNumbering(false); + } + }; + + generateNumberingCode(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableName, columnName, isEditMode, value]); + // 실제 표시할 값 (자동생성 값 또는 props value) const displayValue = autoGeneratedValue ?? value; @@ -520,6 +618,18 @@ export const UnifiedInput = forwardRef((props /> ); + case "numbering": + // 채번 타입: 읽기 전용 텍스트 필드로 표시 (자동 생성) + return ( + {}} + placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"} + readonly={true} + disabled={disabled || isGeneratingNumbering} + /> + ); + default: return ( = ( 여러 줄 텍스트 슬라이더 색상 선택 + 채번 (자동생성)
- + {/* 채번 타입 전용 설정 */} + {config.inputType === "numbering" && ( +
+ +
+

채번 타입 안내

+

+ 채번 규칙은 테이블 관리에서 컬럼별로 설정됩니다. +
+ 화면에 배치된 컬럼의 채번 규칙이 자동으로 적용됩니다. +

+
+ + {/* 채번 필드는 기본적으로 읽기전용 */} +
+ { + updateConfig("readonly", checked); + }} + /> + +
+

+ 채번 필드는 자동으로 생성되므로 읽기전용을 권장합니다 +

+
+ )} - {/* 형식 (텍스트/숫자용) */} - {(config.inputType === "text" || !config.inputType) && ( + {/* 채번 타입이 아닌 경우에만 추가 설정 표시 */} + {config.inputType !== "numbering" && ( + <> + + + {/* 형식 (텍스트/숫자용) */} + {(config.inputType === "text" || !config.inputType) && (