From 7cf455083d3ae025ee469ccfb303a7b28645e5cd Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 4 Nov 2025 13:58:21 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B1=84=EB=B2=88=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../controllers/numberingRuleController.ts | 131 +++++ .../src/services/numberingRuleService.ts | 450 ++++++++++++++++++ docs/채번규칙_컴포넌트_구현_완료.md | 374 +++++++++++++++ .../numbering-rule/AutoConfigPanel.tsx | 149 ++++++ .../numbering-rule/ManualConfigPanel.tsx | 48 ++ .../numbering-rule/NumberingRuleCard.tsx | 101 ++++ .../numbering-rule/NumberingRuleDesigner.tsx | 407 ++++++++++++++++ .../numbering-rule/NumberingRulePreview.tsx | 97 ++++ frontend/lib/api/numberingRule.ts | 81 ++++ frontend/lib/registry/components/index.ts | 1 + .../numbering-rule/NumberingRuleComponent.tsx | 29 ++ .../NumberingRuleConfigPanel.tsx | 105 ++++ .../numbering-rule/NumberingRuleRenderer.tsx | 33 ++ .../components/numbering-rule/README.md | 102 ++++ .../components/numbering-rule/config.ts | 15 + .../components/numbering-rule/index.ts | 42 ++ .../components/numbering-rule/types.ts | 15 + frontend/types/numbering-rule.ts | 117 +++++ 19 files changed, 2299 insertions(+) create mode 100644 backend-node/src/controllers/numberingRuleController.ts create mode 100644 backend-node/src/services/numberingRuleService.ts create mode 100644 docs/채번규칙_컴포넌트_구현_완료.md create mode 100644 frontend/components/numbering-rule/AutoConfigPanel.tsx create mode 100644 frontend/components/numbering-rule/ManualConfigPanel.tsx create mode 100644 frontend/components/numbering-rule/NumberingRuleCard.tsx create mode 100644 frontend/components/numbering-rule/NumberingRuleDesigner.tsx create mode 100644 frontend/components/numbering-rule/NumberingRulePreview.tsx create mode 100644 frontend/lib/api/numberingRule.ts create mode 100644 frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx create mode 100644 frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx create mode 100644 frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx create mode 100644 frontend/lib/registry/components/numbering-rule/README.md create mode 100644 frontend/lib/registry/components/numbering-rule/config.ts create mode 100644 frontend/lib/registry/components/numbering-rule/index.ts create mode 100644 frontend/lib/registry/components/numbering-rule/types.ts create mode 100644 frontend/types/numbering-rule.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index b75e6685..f7c55709 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -64,6 +64,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 +import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -222,6 +223,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 +app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts new file mode 100644 index 00000000..42b6172f --- /dev/null +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -0,0 +1,131 @@ +/** + * 채번 규칙 관리 컨트롤러 + */ + +import { Router, Request, Response } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { numberingRuleService } from "../services/numberingRuleService"; +import { logger } from "../utils/logger"; + +const router = Router(); + +// 규칙 목록 조회 +router.get("/", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + + try { + const rules = await numberingRuleService.getRuleList(companyCode); + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("규칙 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 특정 규칙 조회 +router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + const rule = await numberingRuleService.getRuleById(ruleId, companyCode); + 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 }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 규칙 생성 +router.post("/", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const ruleConfig = req.body; + + try { + if (!ruleConfig.ruleId || !ruleConfig.ruleName) { + return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" }); + } + + if (!Array.isArray(ruleConfig.parts) || ruleConfig.parts.length === 0) { + return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" }); + } + + const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId); + return res.status(201).json({ success: true, data: newRule }); + } catch (error: any) { + if (error.code === "23505") { + return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" }); + } + logger.error("규칙 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 규칙 수정 +router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + const updates = req.body; + + try { + const updatedRule = await numberingRuleService.updateRule(ruleId, updates, companyCode); + return res.json({ success: true, data: updatedRule }); + } catch (error: any) { + if (error.message.includes("찾을 수 없거나")) { + return res.status(404).json({ success: false, error: error.message }); + } + logger.error("규칙 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 규칙 삭제 +router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + await numberingRuleService.deleteRule(ruleId, companyCode); + return res.json({ success: true, message: "규칙이 삭제되었습니다" }); + } catch (error: any) { + if (error.message.includes("찾을 수 없거나")) { + return res.status(404).json({ success: false, error: error.message }); + } + logger.error("규칙 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 코드 생성 +router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode); + return res.json({ success: true, data: { code: generatedCode } }); + } catch (error: any) { + logger.error("코드 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 시퀀스 초기화 +router.post("/:ruleId/reset", authenticateToken, async (req: Request, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + await numberingRuleService.resetSequence(ruleId, companyCode); + return res.json({ success: true, message: "시퀀스가 초기화되었습니다" }); + } catch (error: any) { + logger.error("시퀀스 초기화 실패", { error: error.message }); + 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 new file mode 100644 index 00000000..bd896845 --- /dev/null +++ b/backend-node/src/services/numberingRuleService.ts @@ -0,0 +1,450 @@ +/** + * 채번 규칙 관리 서비스 + */ + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +interface NumberingRulePart { + id?: number; + order: number; + partType: string; + generationMethod: string; + autoConfig?: any; + manualConfig?: any; + generatedValue?: string; +} + +interface NumberingRuleConfig { + ruleId: string; + ruleName: string; + description?: string; + parts: NumberingRulePart[]; + separator?: string; + resetPeriod?: string; + currentSequence?: number; + tableName?: string; + columnName?: string; + companyCode?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; +} + +class NumberingRuleService { + /** + * 규칙 목록 조회 + */ + async getRuleList(companyCode: string): Promise { + try { + logger.info("채번 규칙 목록 조회 시작", { companyCode }); + + 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 + WHERE company_code = $1 OR company_code = '*' + ORDER BY created_at DESC + `; + + const result = await pool.query(query, [companyCode]); + + // 각 규칙의 파트 정보 조회 + 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 + WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') + ORDER BY part_order + `; + + const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + rule.parts = partsResult.rows; + } + + logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { companyCode }); + return result.rows; + } catch (error: any) { + logger.error(`채번 규칙 목록 조회 중 에러: ${error.message}`); + throw error; + } + } + + /** + * 특정 규칙 조회 + */ + async getRuleById(ruleId: string, companyCode: string): Promise { + 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 + WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') + `; + + const result = await pool.query(query, [ruleId, companyCode]); + if (result.rowCount === 0) 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 + WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') + ORDER BY part_order + `; + + const partsResult = await pool.query(partsQuery, [ruleId, companyCode]); + rule.parts = partsResult.rows; + + return rule; + } + + /** + * 규칙 생성 + */ + async createRule( + config: NumberingRuleConfig, + companyCode: string, + userId: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 마스터 삽입 + const insertRuleQuery = ` + INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING + 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" + `; + + const ruleResult = await client.query(insertRuleQuery, [ + config.ruleId, + config.ruleName, + config.description || null, + config.separator || "-", + config.resetPeriod || "none", + config.currentSequence || 1, + config.tableName || null, + config.columnName || null, + companyCode, + userId, + ]); + + // 파트 삽입 + const parts: NumberingRulePart[] = []; + for (const part of config.parts) { + const insertPartQuery = ` + INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + `; + + const partResult = await client.query(insertPartQuery, [ + config.ruleId, + part.order, + part.partType, + part.generationMethod, + JSON.stringify(part.autoConfig || {}), + JSON.stringify(part.manualConfig || {}), + companyCode, + ]); + + parts.push(partResult.rows[0]); + } + + await client.query("COMMIT"); + logger.info("채번 규칙 생성 완료", { ruleId: config.ruleId, companyCode }); + return { ...ruleResult.rows[0], parts }; + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("채번 규칙 생성 실패", { error: error.message }); + throw error; + } finally { + client.release(); + } + } + + /** + * 규칙 수정 + */ + async updateRule( + ruleId: string, + updates: Partial, + companyCode: string + ): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const updateRuleQuery = ` + UPDATE numbering_rules + SET + rule_name = COALESCE($1, rule_name), + description = COALESCE($2, description), + separator = COALESCE($3, separator), + reset_period = COALESCE($4, reset_period), + table_name = COALESCE($5, table_name), + column_name = COALESCE($6, column_name), + updated_at = NOW() + WHERE rule_id = $7 AND company_code = $8 + RETURNING + 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" + `; + + const ruleResult = await client.query(updateRuleQuery, [ + updates.ruleName, + updates.description, + updates.separator, + updates.resetPeriod, + updates.tableName, + updates.columnName, + ruleId, + companyCode, + ]); + + if (ruleResult.rowCount === 0) { + throw new Error("규칙을 찾을 수 없거나 권한이 없습니다"); + } + + // 파트 업데이트 + let parts: NumberingRulePart[] = []; + if (updates.parts) { + await client.query( + "DELETE FROM numbering_rule_parts WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + + for (const part of updates.parts) { + const insertPartQuery = ` + INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + `; + + const partResult = await client.query(insertPartQuery, [ + ruleId, + part.order, + part.partType, + part.generationMethod, + JSON.stringify(part.autoConfig || {}), + JSON.stringify(part.manualConfig || {}), + companyCode, + ]); + + parts.push(partResult.rows[0]); + } + } + + await client.query("COMMIT"); + logger.info("채번 규칙 수정 완료", { ruleId, companyCode }); + return { ...ruleResult.rows[0], parts }; + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("채번 규칙 수정 실패", { error: error.message }); + throw error; + } finally { + client.release(); + } + } + + /** + * 규칙 삭제 + */ + async deleteRule(ruleId: string, companyCode: string): Promise { + const pool = getPool(); + const query = ` + DELETE FROM numbering_rules + WHERE rule_id = $1 AND company_code = $2 + `; + + const result = await pool.query(query, [ruleId, companyCode]); + + if (result.rowCount === 0) { + throw new Error("규칙을 찾을 수 없거나 권한이 없습니다"); + } + + logger.info("채번 규칙 삭제 완료", { ruleId, companyCode }); + } + + /** + * 코드 생성 + */ + async generateCode(ruleId: string, companyCode: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const rule = await this.getRuleById(ruleId, companyCode); + if (!rule) throw new Error("규칙을 찾을 수 없습니다"); + + const parts = rule.parts + .sort((a: any, b: any) => a.order - b.order) + .map((part: any) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || ""; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "prefix": + return autoConfig.prefix || "PREFIX"; + + case "sequence": { + const length = autoConfig.sequenceLength || 4; + return String(rule.currentSequence || 1).padStart(length, "0"); + } + + case "date": + return this.formatDate(new Date(), autoConfig.dateFormat || "YYYYMMDD"); + + case "year": { + const format = autoConfig.dateFormat || "YYYY"; + const year = new Date().getFullYear(); + return format === "YY" ? String(year).slice(-2) : String(year); + } + + case "month": + return String(new Date().getMonth() + 1).padStart(2, "0"); + + case "custom": + return autoConfig.value || "CUSTOM"; + + default: + return ""; + } + }); + + const generatedCode = parts.join(rule.separator || ""); + + const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); + if (hasSequence) { + await client.query( + "UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + } + + await client.query("COMMIT"); + logger.info("코드 생성 완료", { ruleId, generatedCode }); + return generatedCode; + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("코드 생성 실패", { error: error.message }); + throw error; + } finally { + client.release(); + } + } + + private formatDate(date: Date, format: string): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.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}`; + } + } + + async resetSequence(ruleId: string, companyCode: string): Promise { + const pool = getPool(); + await pool.query( + "UPDATE numbering_rules SET current_sequence = 1, updated_at = NOW() WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode] + ); + logger.info("시퀀스 초기화 완료", { ruleId, companyCode }); + } +} + +export const numberingRuleService = new NumberingRuleService(); diff --git a/docs/채번규칙_컴포넌트_구현_완료.md b/docs/채번규칙_컴포넌트_구현_완료.md new file mode 100644 index 00000000..880beb11 --- /dev/null +++ b/docs/채번규칙_컴포넌트_구현_완료.md @@ -0,0 +1,374 @@ +# 채번규칙 컴포넌트 구현 완료 + +> **작성일**: 2025-11-04 +> **상태**: 백엔드 및 프론트엔드 핵심 구현 완료 (화면관리 통합 대기) + +--- + +## 구현 개요 + +채번규칙(Numbering Rule) 컴포넌트는 시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다. + +**생성 코드 예시**: +- 제품 코드: `PROD-20251104-0001` +- 프로젝트 코드: `PRJ-2025-001` +- 거래처 코드: `CUST-A-0001` + +--- + +## 완료된 구현 항목 + +### 1. 데이터베이스 레이어 ✅ + +**파일**: `db/migrations/034_create_numbering_rules.sql` + +- [x] `numbering_rules` 마스터 테이블 생성 +- [x] `numbering_rule_parts` 파트 테이블 생성 +- [x] 멀티테넌시 지원 (company_code 필드) +- [x] 인덱스 생성 (성능 최적화) +- [x] 샘플 데이터 삽입 + +**주요 기능**: +- 규칙 ID, 규칙명, 구분자, 초기화 주기 +- 현재 시퀀스 번호 관리 +- 적용 대상 테이블/컬럼 지정 +- 최대 6개 파트 지원 + +--- + +### 2. 백엔드 레이어 ✅ + +#### 2.1 서비스 레이어 + +**파일**: `backend-node/src/services/numberingRuleService.ts` + +**구현된 메서드**: +- [x] `getRuleList(companyCode)` - 규칙 목록 조회 +- [x] `getRuleById(ruleId, companyCode)` - 특정 규칙 조회 +- [x] `createRule(config, companyCode, userId)` - 규칙 생성 +- [x] `updateRule(ruleId, updates, companyCode)` - 규칙 수정 +- [x] `deleteRule(ruleId, companyCode)` - 규칙 삭제 +- [x] `generateCode(ruleId, companyCode)` - 코드 생성 +- [x] `resetSequence(ruleId, companyCode)` - 시퀀스 초기화 + +**핵심 로직**: +- 트랜잭션 관리 (BEGIN/COMMIT/ROLLBACK) +- 멀티테넌시 필터링 (company_code 기반) +- JSON 설정 직렬화/역직렬화 +- 날짜 형식 변환 (YYYY, YYYYMMDD 등) +- 순번 자동 증가 및 제로 패딩 + +#### 2.2 컨트롤러 레이어 + +**파일**: `backend-node/src/controllers/numberingRuleController.ts` + +**구현된 엔드포인트**: +- [x] `GET /api/numbering-rules` - 규칙 목록 조회 +- [x] `GET /api/numbering-rules/:ruleId` - 특정 규칙 조회 +- [x] `POST /api/numbering-rules` - 규칙 생성 +- [x] `PUT /api/numbering-rules/:ruleId` - 규칙 수정 +- [x] `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제 +- [x] `POST /api/numbering-rules/:ruleId/generate` - 코드 생성 +- [x] `POST /api/numbering-rules/:ruleId/reset` - 시퀀스 초기화 + +**보안 및 검증**: +- `authenticateToken` 미들웨어로 인증 확인 +- 입력값 검증 (필수 필드, 파트 최소 개수) +- 에러 핸들링 및 적절한 HTTP 상태 코드 반환 + +#### 2.3 라우터 등록 + +**파일**: `backend-node/src/app.ts` + +```typescript +import numberingRuleController from "./controllers/numberingRuleController"; +app.use("/api/numbering-rules", numberingRuleController); +``` + +--- + +### 3. 프론트엔드 레이어 ✅ + +#### 3.1 타입 정의 + +**파일**: `frontend/types/numbering-rule.ts` + +**정의된 타입**: +- [x] `CodePartType` - 파트 유형 (prefix/sequence/date/year/month/custom) +- [x] `GenerationMethod` - 생성 방식 (auto/manual) +- [x] `DateFormat` - 날짜 형식 (YYYY/YYYYMMDD 등) +- [x] `NumberingRulePart` - 단일 파트 인터페이스 +- [x] `NumberingRuleConfig` - 전체 규칙 인터페이스 +- [x] 상수 옵션 배열 (UI용) + +#### 3.2 API 클라이언트 + +**파일**: `frontend/lib/api/numberingRule.ts` + +**구현된 함수**: +- [x] `getNumberingRules()` - 규칙 목록 조회 +- [x] `getNumberingRuleById(ruleId)` - 특정 규칙 조회 +- [x] `createNumberingRule(config)` - 규칙 생성 +- [x] `updateNumberingRule(ruleId, config)` - 규칙 수정 +- [x] `deleteNumberingRule(ruleId)` - 규칙 삭제 +- [x] `generateCode(ruleId)` - 코드 생성 +- [x] `resetSequence(ruleId)` - 시퀀스 초기화 + +**기술 스택**: +- Axios 기반 API 클라이언트 +- 에러 핸들링 및 응답 타입 정의 + +#### 3.3 컴포넌트 구조 + +``` +frontend/components/numbering-rule/ +├── NumberingRuleDesigner.tsx # 메인 디자이너 (좌우 분할) +├── NumberingRulePreview.tsx # 실시간 미리보기 +├── NumberingRuleCard.tsx # 단일 파트 카드 +├── AutoConfigPanel.tsx # 자동 생성 설정 +└── ManualConfigPanel.tsx # 직접 입력 설정 +``` + +#### 3.4 주요 컴포넌트 기능 + +**NumberingRuleDesigner** (메인 컴포넌트): +- [x] 좌측: 저장된 규칙 목록 (카드 리스트) +- [x] 우측: 규칙 편집 영역 (파트 추가/수정/삭제) +- [x] 실시간 미리보기 +- [x] 규칙 저장/불러오기/삭제 +- [x] 타이틀 편집 기능 +- [x] 로딩 상태 관리 + +**NumberingRulePreview**: +- [x] 설정된 규칙에 따라 실시간 코드 생성 +- [x] 컴팩트 모드 지원 +- [x] useMemo로 성능 최적화 + +**NumberingRuleCard**: +- [x] 파트 유형 선택 (Select) +- [x] 생성 방식 선택 (자동/수동) +- [x] 동적 설정 패널 표시 +- [x] 삭제 버튼 + +**AutoConfigPanel**: +- [x] 파트 유형별 설정 UI +- [x] 접두사, 순번, 날짜, 연도, 월, 커스텀 +- [x] 입력값 검증 및 가이드 텍스트 + +**ManualConfigPanel**: +- [x] 직접 입력값 설정 +- [x] 플레이스홀더 설정 + +--- + +## 기술적 특징 + +### Shadcn/ui 스타일 가이드 준수 + +- 반응형 크기: `h-8 sm:h-10`, `text-xs sm:text-sm` +- 색상 토큰: `bg-muted`, `text-muted-foreground`, `border-border` +- 간격: `space-y-3 sm:space-y-4`, `gap-4` +- 상태: `hover:bg-accent`, `disabled:opacity-50` + +### 실시간 속성 편집 패턴 + +```typescript +const [currentRule, setCurrentRule] = useState(null); + +useEffect(() => { + if (currentRule) { + onChange?.(currentRule); // 상위 컴포넌트로 실시간 전파 + } +}, [currentRule, onChange]); + +const handleUpdatePart = useCallback((partId: string, updates: Partial) => { + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)), + }; + }); +}, []); +``` + +### 멀티테넌시 지원 + +```typescript +// 백엔드 쿼리 +WHERE company_code = $1 OR company_code = '*' + +// 일반 회사는 자신의 데이터만 조회 +// company_code = "*"는 최고 관리자 전용 데이터 +``` + +### 에러 처리 및 사용자 피드백 + +```typescript +try { + const response = await createNumberingRule(config); + if (response.success) { + toast.success("채번 규칙이 저장되었습니다"); + } else { + toast.error(response.error || "저장 실패"); + } +} catch (error: any) { + toast.error(`저장 실패: ${error.message}`); +} +``` + +--- + +## 남은 작업 + +### 화면관리 시스템 통합 (TODO) + +다음 파일들을 생성하여 화면관리 시스템에 컴포넌트를 등록해야 합니다: + +``` +frontend/lib/registry/components/numbering-rule/ +├── index.ts # 컴포넌트 정의 및 등록 +├── NumberingRuleComponent.tsx # 래퍼 컴포넌트 +├── NumberingRuleConfigPanel.tsx # 속성 설정 패널 +└── types.ts # 컴포넌트 설정 타입 +``` + +**등록 예시**: +```typescript +export const NumberingRuleDefinition = createComponentDefinition({ + id: "numbering-rule", + name: "코드 채번 규칙", + category: ComponentCategory.ADMIN, + component: NumberingRuleWrapper, + configPanel: NumberingRuleConfigPanel, + defaultSize: { width: 1200, height: 800 }, + icon: "Hash", + tags: ["코드", "채번", "규칙", "관리자"], +}); +``` + +--- + +## 테스트 가이드 + +### 백엔드 API 테스트 (Postman/Thunder Client) + +#### 1. 규칙 목록 조회 +```bash +GET http://localhost:8080/api/numbering-rules +Authorization: Bearer {token} +``` + +#### 2. 규칙 생성 +```bash +POST http://localhost:8080/api/numbering-rules +Content-Type: application/json +Authorization: Bearer {token} + +{ + "ruleId": "PROD_CODE", + "ruleName": "제품 코드 규칙", + "separator": "-", + "parts": [ + { + "order": 1, + "partType": "prefix", + "generationMethod": "auto", + "autoConfig": { "prefix": "PROD" } + }, + { + "order": 2, + "partType": "date", + "generationMethod": "auto", + "autoConfig": { "dateFormat": "YYYYMMDD" } + }, + { + "order": 3, + "partType": "sequence", + "generationMethod": "auto", + "autoConfig": { "sequenceLength": 4, "startFrom": 1 } + } + ] +} +``` + +#### 3. 코드 생성 +```bash +POST http://localhost:8080/api/numbering-rules/PROD_CODE/generate +Authorization: Bearer {token} + +Response: +{ + "success": true, + "data": { + "code": "PROD-20251104-0001" + } +} +``` + +### 프론트엔드 테스트 + +1. **새 규칙 생성**: + - "새 규칙 생성" 버튼 클릭 + - 규칙명 입력 + - "규칙 추가" 버튼으로 파트 추가 + - 각 파트의 설정 변경 + - "저장" 버튼 클릭 + +2. **미리보기 확인**: + - 파트 추가/수정 시 실시간으로 코드 미리보기 업데이트 확인 + - 구분자 변경 시 반영 확인 + +3. **규칙 편집**: + - 좌측 목록에서 규칙 선택 + - 우측 편집 영역에서 수정 + - 저장 후 목록에 반영 확인 + +4. **규칙 삭제**: + - 목록 카드의 삭제 버튼 클릭 + - 목록에서 제거 확인 + +--- + +## 파일 목록 + +### 백엔드 +- `db/migrations/034_create_numbering_rules.sql` (마이그레이션) +- `backend-node/src/services/numberingRuleService.ts` (서비스) +- `backend-node/src/controllers/numberingRuleController.ts` (컨트롤러) +- `backend-node/src/app.ts` (라우터 등록) + +### 프론트엔드 +- `frontend/types/numbering-rule.ts` (타입 정의) +- `frontend/lib/api/numberingRule.ts` (API 클라이언트) +- `frontend/components/numbering-rule/NumberingRuleDesigner.tsx` +- `frontend/components/numbering-rule/NumberingRulePreview.tsx` +- `frontend/components/numbering-rule/NumberingRuleCard.tsx` +- `frontend/components/numbering-rule/AutoConfigPanel.tsx` +- `frontend/components/numbering-rule/ManualConfigPanel.tsx` + +--- + +## 다음 단계 + +1. **마이그레이션 실행**: + ```sql + psql -U postgres -d ilshin -f db/migrations/034_create_numbering_rules.sql + ``` + +2. **백엔드 서버 확인** (이미 실행 중이면 자동 반영) + +3. **화면관리 통합**: + - 레지스트리 컴포넌트 파일 생성 + - 컴포넌트 등록 및 화면 디자이너에서 사용 가능하도록 설정 + +4. **테스트**: + - API 테스트 (Postman) + - UI 테스트 (브라우저) + - 멀티테넌시 검증 + +--- + +**작성 완료**: 2025-11-04 +**문의**: 백엔드 및 프론트엔드 핵심 기능 완료, 화면관리 통합만 남음 + diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx new file mode 100644 index 00000000..1f54cae6 --- /dev/null +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -0,0 +1,149 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { CodePartType, DATE_FORMAT_OPTIONS } from "@/types/numbering-rule"; + +interface AutoConfigPanelProps { + partType: CodePartType; + config?: any; + onChange: (config: any) => void; + isPreview?: boolean; +} + +export const AutoConfigPanel: React.FC = ({ + partType, + config = {}, + onChange, + isPreview = false, +}) => { + if (partType === "prefix") { + return ( +
+ + onChange({ ...config, prefix: e.target.value })} + placeholder="예: PROD" + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 코드 앞에 붙을 고정 문자열 +

+
+ ); + } + + if (partType === "sequence") { + return ( +
+
+ + + onChange({ ...config, sequenceLength: parseInt(e.target.value) || 4 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 예: 4 → 0001, 5 → 00001 +

+
+
+ + + onChange({ ...config, startFrom: parseInt(e.target.value) || 1 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ ); + } + + if (partType === "date") { + return ( +
+ + +
+ ); + } + + if (partType === "year") { + return ( +
+ + +
+ ); + } + + if (partType === "month") { + return ( +
+ +

+ 현재 월이 2자리 형식(01-12)으로 자동 입력됩니다 +

+
+ ); + } + + if (partType === "custom") { + return ( +
+ + onChange({ ...config, value: e.target.value })} + placeholder="입력값" + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ ); + } + + return null; +}; diff --git a/frontend/components/numbering-rule/ManualConfigPanel.tsx b/frontend/components/numbering-rule/ManualConfigPanel.tsx new file mode 100644 index 00000000..636b7914 --- /dev/null +++ b/frontend/components/numbering-rule/ManualConfigPanel.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +interface ManualConfigPanelProps { + config?: { + value?: string; + placeholder?: string; + }; + onChange: (config: any) => void; + isPreview?: boolean; +} + +export const ManualConfigPanel: React.FC = ({ + config = {}, + onChange, + isPreview = false, +}) => { + return ( +
+
+ + onChange({ ...config, value: e.target.value })} + placeholder={config.placeholder || "값을 입력하세요"} + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

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

+
+
+ + onChange({ ...config, placeholder: e.target.value })} + placeholder="예: 부서코드 입력" + 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 new file mode 100644 index 00000000..a6f2cab3 --- /dev/null +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -0,0 +1,101 @@ +"use client"; + +import React from "react"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; +import { Trash2 } from "lucide-react"; +import { NumberingRulePart, CodePartType, GenerationMethod, CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule"; +import { AutoConfigPanel } from "./AutoConfigPanel"; +import { ManualConfigPanel } from "./ManualConfigPanel"; + +interface NumberingRuleCardProps { + part: NumberingRulePart; + onUpdate: (updates: Partial) => void; + onDelete: () => void; + isPreview?: boolean; +} + +export const NumberingRuleCard: React.FC = ({ + part, + onUpdate, + onDelete, + isPreview = false, +}) => { + return ( + + +
+ + 규칙 {part.order} + + +
+
+ + +
+ + +
+ +
+ + +
+ + {part.generationMethod === "auto" ? ( + onUpdate({ autoConfig })} + isPreview={isPreview} + /> + ) : ( + onUpdate({ manualConfig })} + isPreview={isPreview} + /> + )} +
+
+ ); +}; diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx new file mode 100644 index 00000000..d318feb0 --- /dev/null +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -0,0 +1,407 @@ +"use client"; + +import React, { useState, useCallback, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Plus, Save, Edit2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule"; +import { NumberingRuleCard } from "./NumberingRuleCard"; +import { NumberingRulePreview } from "./NumberingRulePreview"; +import { + getNumberingRules, + createNumberingRule, + updateNumberingRule, + deleteNumberingRule, +} from "@/lib/api/numberingRule"; + +interface NumberingRuleDesignerProps { + initialConfig?: NumberingRuleConfig; + onSave?: (config: NumberingRuleConfig) => void; + onChange?: (config: NumberingRuleConfig) => void; + maxRules?: number; + isPreview?: boolean; + className?: string; +} + +export const NumberingRuleDesigner: React.FC = ({ + initialConfig, + onSave, + onChange, + maxRules = 6, + isPreview = false, + className = "", +}) => { + const [savedRules, setSavedRules] = useState([]); + const [selectedRuleId, setSelectedRuleId] = useState(null); + const [currentRule, setCurrentRule] = useState(null); + const [loading, setLoading] = useState(false); + const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록"); + const [rightTitle, setRightTitle] = useState("규칙 편집"); + const [editingLeftTitle, setEditingLeftTitle] = useState(false); + const [editingRightTitle, setEditingRightTitle] = useState(false); + + useEffect(() => { + loadRules(); + }, []); + + const loadRules = useCallback(async () => { + setLoading(true); + try { + const response = await getNumberingRules(); + if (response.success && response.data) { + setSavedRules(response.data); + } else { + toast.error(response.error || "규칙 목록을 불러올 수 없습니다"); + } + } catch (error: any) { + toast.error(`로딩 실패: ${error.message}`); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (currentRule) { + onChange?.(currentRule); + } + }, [currentRule, onChange]); + + const handleAddPart = useCallback(() => { + if (!currentRule) return; + + if (currentRule.parts.length >= maxRules) { + toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`); + return; + } + + const newPart: NumberingRulePart = { + id: `part-${Date.now()}`, + order: currentRule.parts.length + 1, + partType: "prefix", + generationMethod: "auto", + autoConfig: { prefix: "CODE" }, + }; + + setCurrentRule((prev) => { + if (!prev) return null; + return { ...prev, parts: [...prev.parts, newPart] }; + }); + + toast.success(`규칙 ${newPart.order}가 추가되었습니다`); + }, [currentRule, maxRules]); + + const handleUpdatePart = useCallback((partId: string, updates: Partial) => { + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => (part.id === partId ? { ...part, ...updates } : part)), + }; + }); + }, []); + + const handleDeletePart = useCallback((partId: string) => { + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts + .filter((part) => part.id !== partId) + .map((part, index) => ({ ...part, order: index + 1 })), + }; + }); + + toast.success("규칙이 삭제되었습니다"); + }, []); + + const handleSave = useCallback(async () => { + if (!currentRule) { + toast.error("저장할 규칙이 없습니다"); + return; + } + + if (currentRule.parts.length === 0) { + toast.error("최소 1개 이상의 규칙을 추가해주세요"); + return; + } + + setLoading(true); + try { + const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId); + + let response; + if (existing) { + response = await updateNumberingRule(currentRule.ruleId, currentRule); + } else { + response = await createNumberingRule(currentRule); + } + + if (response.success && response.data) { + setSavedRules((prev) => { + if (existing) { + return prev.map((r) => (r.ruleId === currentRule.ruleId ? response.data! : r)); + } else { + return [...prev, response.data!]; + } + }); + + setCurrentRule(response.data); + setSelectedRuleId(response.data.ruleId); + + await onSave?.(response.data); + toast.success("채번 규칙이 저장되었습니다"); + } else { + toast.error(response.error || "저장 실패"); + } + } catch (error: any) { + toast.error(`저장 실패: ${error.message}`); + } finally { + setLoading(false); + } + }, [currentRule, savedRules, onSave]); + + const handleSelectRule = useCallback((rule: NumberingRuleConfig) => { + setSelectedRuleId(rule.ruleId); + setCurrentRule(rule); + toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`); + }, []); + + const handleDeleteSavedRule = useCallback(async (ruleId: string) => { + setLoading(true); + try { + const response = await deleteNumberingRule(ruleId); + + if (response.success) { + setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId)); + + if (selectedRuleId === ruleId) { + setSelectedRuleId(null); + setCurrentRule(null); + } + + toast.success("규칙이 삭제되었습니다"); + } else { + toast.error(response.error || "삭제 실패"); + } + } catch (error: any) { + toast.error(`삭제 실패: ${error.message}`); + } finally { + setLoading(false); + } + }, [selectedRuleId]); + + const handleNewRule = useCallback(() => { + const newRule: NumberingRuleConfig = { + ruleId: `rule-${Date.now()}`, + ruleName: "새 채번 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + }; + + setSelectedRuleId(newRule.ruleId); + setCurrentRule(newRule); + + toast.success("새 규칙이 생성되었습니다"); + }, []); + + return ( +
+ {/* 좌측: 저장된 규칙 목록 */} +
+
+ {editingLeftTitle ? ( + setLeftTitle(e.target.value)} + onBlur={() => setEditingLeftTitle(false)} + onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)} + className="h-8 text-sm font-semibold" + autoFocus + /> + ) : ( +

{leftTitle}

+ )} + +
+ + + +
+ {loading ? ( +
+

로딩 중...

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

저장된 규칙이 없습니다

+
+ ) : ( + savedRules.map((rule) => ( + handleSelectRule(rule)} + > + +
+
+ {rule.ruleName} +

+ 규칙 {rule.parts.length}개 +

+
+ +
+
+ + + +
+ )) + )} +
+
+ + {/* 구분선 */} +
+ + {/* 우측: 편집 영역 */} +
+ {!currentRule ? ( +
+
+

+ 규칙을 선택해주세요 +

+

+ 좌측에서 규칙을 선택하거나 새로 생성하세요 +

+
+
+ ) : ( + <> +
+ {editingRightTitle ? ( + setRightTitle(e.target.value)} + onBlur={() => setEditingRightTitle(false)} + onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)} + className="h-8 text-sm font-semibold" + autoFocus + /> + ) : ( +

{rightTitle}

+ )} + +
+ +
+ + + setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value })) + } + className="h-9" + placeholder="예: 프로젝트 코드" + /> +
+ + + + 미리보기 + + + + + + +
+
+

코드 구성

+ + {currentRule.parts.length}/{maxRules} + +
+ + {currentRule.parts.length === 0 ? ( +
+

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

+
+ ) : ( +
+ {currentRule.parts.map((part) => ( + handleUpdatePart(part.id, updates)} + onDelete={() => handleDeletePart(part.id)} + isPreview={isPreview} + /> + ))} +
+ )} +
+ +
+ + +
+ + )} +
+
+ ); +}; diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx new file mode 100644 index 00000000..38e9dbfd --- /dev/null +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -0,0 +1,97 @@ +"use client"; + +import React, { useMemo } from "react"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +interface NumberingRulePreviewProps { + config: NumberingRuleConfig; + compact?: boolean; +} + +export const NumberingRulePreview: React.FC = ({ + config, + compact = false +}) => { + const generatedCode = useMemo(() => { + if (!config.parts || config.parts.length === 0) { + return "규칙을 추가해주세요"; + } + + const parts = config.parts + .sort((a, b) => a.order - b.order) + .map((part) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "prefix": + return autoConfig.prefix || "PREFIX"; + + case "sequence": { + const length = autoConfig.sequenceLength || 4; + const startFrom = autoConfig.startFrom || 1; + return String(startFrom).padStart(length, "0"); + } + + case "date": { + const format = autoConfig.dateFormat || "YYYYMMDD"; + 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 "year": { + const now = new Date(); + const format = autoConfig.dateFormat || "YYYY"; + return format === "YY" + ? String(now.getFullYear()).slice(-2) + : String(now.getFullYear()); + } + + case "month": { + const now = new Date(); + return String(now.getMonth() + 1).padStart(2, "0"); + } + + case "custom": + return autoConfig.value || "CUSTOM"; + + default: + return "XXX"; + } + }); + + return parts.join(config.separator || ""); + }, [config]); + + if (compact) { + return ( +
+ {generatedCode} +
+ ); + } + + return ( +
+

코드 미리보기

+
+ {generatedCode} +
+
+ ); +}; diff --git a/frontend/lib/api/numberingRule.ts b/frontend/lib/api/numberingRule.ts new file mode 100644 index 00000000..7702ea08 --- /dev/null +++ b/frontend/lib/api/numberingRule.ts @@ -0,0 +1,81 @@ +/** + * 채번 규칙 관리 API 클라이언트 + */ + +import { apiClient } from "./client"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export async function getNumberingRules(): Promise> { + try { + const response = await apiClient.get("/numbering-rules"); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 목록 조회 실패" }; + } +} + +export async function getNumberingRuleById(ruleId: string): Promise> { + try { + const response = await apiClient.get(`/numbering-rules/${ruleId}`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 조회 실패" }; + } +} + +export async function createNumberingRule( + config: NumberingRuleConfig +): Promise> { + try { + const response = await apiClient.post("/numbering-rules", config); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 생성 실패" }; + } +} + +export async function updateNumberingRule( + ruleId: string, + config: Partial +): Promise> { + try { + const response = await apiClient.put(`/numbering-rules/${ruleId}`, config); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 수정 실패" }; + } +} + +export async function deleteNumberingRule(ruleId: string): Promise> { + try { + const response = await apiClient.delete(`/numbering-rules/${ruleId}`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "규칙 삭제 실패" }; + } +} + +export async function generateCode(ruleId: string): Promise> { + try { + const response = await apiClient.post(`/numbering-rules/${ruleId}/generate`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "코드 생성 실패" }; + } +} + +export async function resetSequence(ruleId: string): Promise> { + try { + const response = await apiClient.post(`/numbering-rules/${ruleId}/reset`); + return response.data; + } catch (error: any) { + return { success: false, error: error.message || "시퀀스 초기화 실패" }; + } +} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index f2ac68c2..315cf1da 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -39,6 +39,7 @@ import "./split-panel-layout/SplitPanelLayoutRenderer"; import "./map/MapRenderer"; import "./repeater-field-group/RepeaterFieldGroupRenderer"; import "./flow-widget/FlowWidgetRenderer"; +import "./numbering-rule/NumberingRuleRenderer"; /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx new file mode 100644 index 00000000..78c366fd --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/NumberingRuleComponent.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React from "react"; +import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner"; +import { NumberingRuleComponentConfig } from "./types"; + +interface NumberingRuleWrapperProps { + config: NumberingRuleComponentConfig; + onChange?: (config: NumberingRuleComponentConfig) => void; + isPreview?: boolean; +} + +export const NumberingRuleWrapper: React.FC = ({ + config, + onChange, + isPreview = false, +}) => { + return ( +
+ +
+ ); +}; + +export const NumberingRuleComponent = NumberingRuleWrapper; diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx new file mode 100644 index 00000000..332d4055 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/NumberingRuleConfigPanel.tsx @@ -0,0 +1,105 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { NumberingRuleComponentConfig } from "./types"; + +interface NumberingRuleConfigPanelProps { + config: NumberingRuleComponentConfig; + onChange: (config: NumberingRuleComponentConfig) => void; +} + +export const NumberingRuleConfigPanel: React.FC = ({ + config, + onChange, +}) => { + return ( +
+
+ + + onChange({ ...config, maxRules: parseInt(e.target.value) || 6 }) + } + className="h-9" + /> +

+ 한 규칙에 추가할 수 있는 최대 파트 개수 (1-10) +

+
+ +
+
+ +

+ 편집 기능을 비활성화합니다 +

+
+ + onChange({ ...config, readonly: checked }) + } + /> +
+ +
+
+ +

+ 코드 미리보기를 항상 표시합니다 +

+
+ + onChange({ ...config, showPreview: checked }) + } + /> +
+ +
+
+ +

+ 저장된 규칙 목록을 표시합니다 +

+
+ + onChange({ ...config, showRuleList: checked }) + } + /> +
+ +
+ + +

+ 규칙 파트 카드의 배치 방향 +

+
+
+ ); +}; diff --git a/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx b/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx new file mode 100644 index 00000000..29c98b45 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/NumberingRuleRenderer.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { NumberingRuleDefinition } from "./index"; +import { NumberingRuleComponent } from "./NumberingRuleComponent"; + +/** + * 채번 규칙 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class NumberingRuleRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = NumberingRuleDefinition; + + render(): React.ReactElement { + return ; + } + + /** + * 채번 규칙 컴포넌트 특화 메서드 + */ + protected handleValueChange = (value: any) => { + this.updateComponent({ value }); + }; +} + +// 자동 등록 실행 +NumberingRuleRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + NumberingRuleRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/numbering-rule/README.md b/frontend/lib/registry/components/numbering-rule/README.md new file mode 100644 index 00000000..5d04d894 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/README.md @@ -0,0 +1,102 @@ +# 코드 채번 규칙 컴포넌트 + +## 개요 + +시스템에서 자동으로 코드를 생성하는 규칙을 설정하고 관리하는 관리자 전용 컴포넌트입니다. + +## 주요 기능 + +- **좌우 분할 레이아웃**: 좌측에서 규칙 목록, 우측에서 편집 +- **동적 파트 시스템**: 최대 6개의 파트를 자유롭게 조합 +- **실시간 미리보기**: 설정 즉시 생성될 코드 확인 +- **다양한 파트 유형**: 접두사, 순번, 날짜, 연도, 월, 커스텀 + +## 생성 코드 예시 + +- 제품 코드: `PROD-20251104-0001` +- 프로젝트 코드: `PRJ-2025-001` +- 거래처 코드: `CUST-A-0001` + +## 파트 유형 + +### 1. 접두사 (prefix) +고정된 문자열을 코드 앞에 추가합니다. +- 예: `PROD`, `PRJ`, `CUST` + +### 2. 순번 (sequence) +자동으로 증가하는 번호를 생성합니다. +- 자릿수 설정 가능 (1-10) +- 시작 번호 설정 가능 +- 예: `0001`, `00001` + +### 3. 날짜 (date) +현재 날짜를 다양한 형식으로 추가합니다. +- YYYY: 2025 +- YYYYMMDD: 20251104 +- YYMMDD: 251104 + +### 4. 연도 (year) +현재 연도를 추가합니다. +- YYYY: 2025 +- YY: 25 + +### 5. 월 (month) +현재 월을 2자리로 추가합니다. +- 예: 01, 02, ..., 12 + +### 6. 사용자 정의 (custom) +원하는 값을 직접 입력합니다. + +## 생성 방식 + +### 자동 생성 (auto) +시스템이 자동으로 값을 생성합니다. + +### 직접 입력 (manual) +사용자가 값을 직접 입력합니다. + +## 설정 옵션 + +| 옵션 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `maxRules` | number | 6 | 최대 파트 개수 | +| `readonly` | boolean | false | 읽기 전용 모드 | +| `showPreview` | boolean | true | 미리보기 표시 | +| `showRuleList` | boolean | true | 규칙 목록 표시 | +| `cardLayout` | "vertical" \| "horizontal" | "vertical" | 카드 배치 방향 | + +## 사용 예시 + +```typescript + +``` + +## 데이터베이스 구조 + +### numbering_rules (마스터 테이블) +- 규칙 ID, 규칙명, 구분자 +- 초기화 주기, 현재 시퀀스 +- 적용 대상 테이블/컬럼 + +### numbering_rule_parts (파트 테이블) +- 파트 순서, 파트 유형 +- 생성 방식, 설정 (JSONB) + +## API 엔드포인트 + +- `GET /api/numbering-rules` - 규칙 목록 조회 +- `POST /api/numbering-rules` - 규칙 생성 +- `PUT /api/numbering-rules/:ruleId` - 규칙 수정 +- `DELETE /api/numbering-rules/:ruleId` - 규칙 삭제 +- `POST /api/numbering-rules/:ruleId/generate` - 코드 생성 + +## 버전 정보 + +- **버전**: 1.0.0 +- **작성일**: 2025-11-04 +- **작성자**: 개발팀 + diff --git a/frontend/lib/registry/components/numbering-rule/config.ts b/frontend/lib/registry/components/numbering-rule/config.ts new file mode 100644 index 00000000..87e5c996 --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/config.ts @@ -0,0 +1,15 @@ +/** + * 채번 규칙 컴포넌트 기본 설정 + */ + +import { NumberingRuleComponentConfig } from "./types"; + +export const defaultConfig: NumberingRuleComponentConfig = { + maxRules: 6, + readonly: false, + showPreview: true, + showRuleList: true, + enableReorder: false, + cardLayout: "vertical", +}; + diff --git a/frontend/lib/registry/components/numbering-rule/index.ts b/frontend/lib/registry/components/numbering-rule/index.ts new file mode 100644 index 00000000..6399ab2a --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/index.ts @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { NumberingRuleWrapper } from "./NumberingRuleComponent"; +import { NumberingRuleConfigPanel } from "./NumberingRuleConfigPanel"; +import { defaultConfig } from "./config"; + +/** + * 채번 규칙 컴포넌트 정의 + * 코드 자동 채번 규칙을 설정하고 관리하는 관리자 전용 컴포넌트 + */ +export const NumberingRuleDefinition = createComponentDefinition({ + id: "numbering-rule", + name: "코드 채번 규칙", + nameEng: "Numbering Rule Component", + description: "코드 자동 채번 규칙을 설정하고 관리하는 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "component", + component: NumberingRuleWrapper, + defaultConfig: defaultConfig, + defaultSize: { + width: 1200, + height: 800, + gridColumnSpan: "12", + }, + configPanel: NumberingRuleConfigPanel, + icon: "Hash", + tags: ["코드", "채번", "규칙", "표시", "자동생성"], + version: "1.0.0", + author: "개발팀", + documentation: "코드 자동 채번 규칙을 설정합니다. 접두사, 날짜, 순번 등을 조합하여 고유한 코드를 생성할 수 있습니다.", +}); + +// 타입 내보내기 +export type { NumberingRuleComponentConfig } from "./types"; + +// 컴포넌트 내보내기 +export { NumberingRuleComponent } from "./NumberingRuleComponent"; +export { NumberingRuleRenderer } from "./NumberingRuleRenderer"; + diff --git a/frontend/lib/registry/components/numbering-rule/types.ts b/frontend/lib/registry/components/numbering-rule/types.ts new file mode 100644 index 00000000..43def2cb --- /dev/null +++ b/frontend/lib/registry/components/numbering-rule/types.ts @@ -0,0 +1,15 @@ +/** + * 채번 규칙 컴포넌트 타입 정의 + */ + +import { NumberingRuleConfig } from "@/types/numbering-rule"; + +export interface NumberingRuleComponentConfig { + ruleConfig?: NumberingRuleConfig; + maxRules?: number; + readonly?: boolean; + showPreview?: boolean; + showRuleList?: boolean; + enableReorder?: boolean; + cardLayout?: "vertical" | "horizontal"; +} diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts new file mode 100644 index 00000000..dbdbf9bd --- /dev/null +++ b/frontend/types/numbering-rule.ts @@ -0,0 +1,117 @@ +/** + * 코드 채번 규칙 컴포넌트 타입 정의 + * Shadcn/ui 가이드라인 기반 + */ + +/** + * 코드 파트 유형 + */ +export type CodePartType = + | "prefix" // 접두사 (고정 문자열) + | "sequence" // 순번 (자동 증가) + | "date" // 날짜 (YYYYMMDD 등) + | "year" // 연도 (YYYY) + | "month" // 월 (MM) + | "custom"; // 사용자 정의 + +/** + * 생성 방식 + */ +export type GenerationMethod = + | "auto" // 자동 생성 + | "manual"; // 직접 입력 + +/** + * 날짜 형식 + */ +export type DateFormat = + | "YYYY" // 2025 + | "YY" // 25 + | "YYYYMM" // 202511 + | "YYMM" // 2511 + | "YYYYMMDD" // 20251104 + | "YYMMDD"; // 251104 + +/** + * 단일 규칙 파트 + */ +export interface NumberingRulePart { + id: string; // 고유 ID + order: number; // 순서 (1-6) + partType: CodePartType; // 파트 유형 + generationMethod: GenerationMethod; // 생성 방식 + + // 자동 생성 설정 + autoConfig?: { + prefix?: string; // 접두사 + sequenceLength?: number; // 순번 자릿수 + startFrom?: number; // 시작 번호 + dateFormat?: DateFormat; // 날짜 형식 + value?: string; // 커스텀 값 + }; + + // 직접 입력 설정 + manualConfig?: { + value: string; // 입력값 + placeholder?: string; // 플레이스홀더 + }; + + // 생성된 값 (미리보기용) + generatedValue?: string; +} + +/** + * 전체 채번 규칙 + */ +export interface NumberingRuleConfig { + ruleId: string; // 규칙 ID + ruleName: string; // 규칙명 + description?: string; // 설명 + parts: NumberingRulePart[]; // 규칙 파트 배열 + + // 설정 + separator?: string; // 구분자 (기본: "-") + resetPeriod?: "none" | "daily" | "monthly" | "yearly"; + currentSequence?: number; // 현재 시퀀스 + + // 적용 대상 + tableName?: string; // 적용할 테이블명 + columnName?: string; // 적용할 컬럼명 + + // 메타 정보 + companyCode?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; +} + +/** + * UI 옵션 상수 + */ +export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string }> = [ + { value: "prefix", label: "접두사" }, + { value: "sequence", label: "순번" }, + { value: "date", label: "날짜" }, + { value: "year", label: "연도" }, + { value: "month", label: "월" }, + { value: "custom", label: "사용자 정의" }, +]; + +export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [ + { value: "YYYY", label: "연도 (4자리)", example: "2025" }, + { value: "YY", label: "연도 (2자리)", example: "25" }, + { value: "YYYYMM", label: "연도+월", example: "202511" }, + { value: "YYMM", label: "연도(2)+월", example: "2511" }, + { value: "YYYYMMDD", label: "연월일", example: "20251104" }, + { value: "YYMMDD", label: "연(2)+월일", example: "251104" }, +]; + +export const RESET_PERIOD_OPTIONS: Array<{ + value: "none" | "daily" | "monthly" | "yearly"; + label: string; +}> = [ + { value: "none", label: "초기화 안함" }, + { value: "daily", label: "일별 초기화" }, + { value: "monthly", label: "월별 초기화" }, + { value: "yearly", label: "연별 초기화" }, +];