채번 컴포넌트 생성

This commit is contained in:
kjs
2025-11-04 13:58:21 +09:00
parent 2f9b4f27b8
commit 7cf455083d
19 changed files with 2299 additions and 0 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<NumberingRuleConfig[]> {
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<NumberingRuleConfig | null> {
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<NumberingRuleConfig> {
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<NumberingRuleConfig>,
companyCode: string
): Promise<NumberingRuleConfig> {
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<void> {
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<string> {
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<void> {
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();

View File

@@ -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<NumberingRuleConfig | null>(null);
useEffect(() => {
if (currentRule) {
onChange?.(currentRule); // 상위 컴포넌트로 실시간 전파
}
}, [currentRule, onChange]);
const handleUpdatePart = useCallback((partId: string, updates: Partial<NumberingRulePart>) => {
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
**문의**: 백엔드 및 프론트엔드 핵심 기능 완료, 화면관리 통합만 남음

View File

@@ -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<AutoConfigPanelProps> = ({
partType,
config = {},
onChange,
isPreview = false,
}) => {
if (partType === "prefix") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"></Label>
<Input
value={config.prefix || ""}
onChange={(e) => onChange({ ...config, prefix: e.target.value })}
placeholder="예: PROD"
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
);
}
if (partType === "sequence") {
return (
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs font-medium sm:text-sm"> 릿</Label>
<Input
type="number"
min={1}
max={10}
value={config.sequenceLength || 4}
onChange={(e) =>
onChange({ ...config, sequenceLength: parseInt(e.target.value) || 4 })
}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
: 4 0001, 5 00001
</p>
</div>
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Input
type="number"
min={1}
value={config.startFrom || 1}
onChange={(e) =>
onChange({ ...config, startFrom: parseInt(e.target.value) || 1 })
}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
);
}
if (partType === "date") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.dateFormat || "YYYYMMDD"}
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
disabled={isPreview}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMAT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
{option.label} ({option.example})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
if (partType === "year") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={config.dateFormat || "YYYY"}
onValueChange={(value) => onChange({ ...config, dateFormat: value })}
disabled={isPreview}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYY" className="text-xs sm:text-sm">4 (2025)</SelectItem>
<SelectItem value="YY" className="text-xs sm:text-sm">2 (25)</SelectItem>
</SelectContent>
</Select>
</div>
);
}
if (partType === "month") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
2 (01-12)
</p>
</div>
);
}
if (partType === "custom") {
return (
<div>
<Label className="text-xs font-medium sm:text-sm"></Label>
<Input
value={config.value || ""}
onChange={(e) => onChange({ ...config, value: e.target.value })}
placeholder="입력값"
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
);
}
return null;
};

View File

@@ -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<ManualConfigPanelProps> = ({
config = {},
onChange,
isPreview = false,
}) => {
return (
<div className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs font-medium sm:text-sm"></Label>
<Input
value={config.value || ""}
onChange={(e) => onChange({ ...config, value: e.target.value })}
placeholder={config.placeholder || "값을 입력하세요"}
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
</p>
</div>
<div>
<Label className="text-xs font-medium sm:text-sm"> ()</Label>
<Input
value={config.placeholder || ""}
onChange={(e) => onChange({ ...config, placeholder: e.target.value })}
placeholder="예: 부서코드 입력"
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
);
};

View File

@@ -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<NumberingRulePart>) => void;
onDelete: () => void;
isPreview?: boolean;
}
export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
part,
onUpdate,
onDelete,
isPreview = false,
}) => {
return (
<Card className="border-border bg-card">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs sm:text-sm">
{part.order}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={onDelete}
className="h-7 w-7 text-destructive sm:h-8 sm:w-8"
disabled={isPreview}
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={part.partType}
onValueChange={(value) => onUpdate({ partType: value as CodePartType })}
disabled={isPreview}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CODE_PART_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-xs sm:text-sm">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Select
value={part.generationMethod}
onValueChange={(value) => onUpdate({ generationMethod: value as GenerationMethod })}
disabled={isPreview}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-xs sm:text-sm"> </SelectItem>
<SelectItem value="manual" className="text-xs sm:text-sm"> </SelectItem>
</SelectContent>
</Select>
</div>
{part.generationMethod === "auto" ? (
<AutoConfigPanel
partType={part.partType}
config={part.autoConfig}
onChange={(autoConfig) => onUpdate({ autoConfig })}
isPreview={isPreview}
/>
) : (
<ManualConfigPanel
config={part.manualConfig}
onChange={(manualConfig) => onUpdate({ manualConfig })}
isPreview={isPreview}
/>
)}
</CardContent>
</Card>
);
};

View File

@@ -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<NumberingRuleDesignerProps> = ({
initialConfig,
onSave,
onChange,
maxRules = 6,
isPreview = false,
className = "",
}) => {
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [currentRule, setCurrentRule] = useState<NumberingRuleConfig | null>(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<NumberingRulePart>) => {
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 (
<div className={`flex h-full gap-4 ${className}`}>
{/* 좌측: 저장된 규칙 목록 */}
<div className="flex w-80 flex-shrink-0 flex-col gap-4">
<div className="flex items-center justify-between">
{editingLeftTitle ? (
<Input
value={leftTitle}
onChange={(e) => setLeftTitle(e.target.value)}
onBlur={() => setEditingLeftTitle(false)}
onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)}
className="h-8 text-sm font-semibold"
autoFocus
/>
) : (
<h2 className="text-sm font-semibold sm:text-base">{leftTitle}</h2>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingLeftTitle(true)}
>
<Edit2 className="h-3 w-3" />
</Button>
</div>
<Button onClick={handleNewRule} variant="outline" className="h-9 w-full text-sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
<div className="flex-1 space-y-2 overflow-y-auto">
{loading ? (
<div className="flex h-32 items-center justify-center">
<p className="text-xs text-muted-foreground"> ...</p>
</div>
) : savedRules.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
savedRules.map((rule) => (
<Card
key={rule.ruleId}
className={`cursor-pointer border-border transition-colors hover:bg-accent ${
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
}`}
onClick={() => handleSelectRule(rule)}
>
<CardHeader className="p-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-sm font-medium">{rule.ruleName}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{rule.parts.length}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
handleDeleteSavedRule(rule.ruleId);
}}
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
<NumberingRulePreview config={rule} compact />
</CardContent>
</Card>
))
)}
</div>
</div>
{/* 구분선 */}
<div className="h-full w-px bg-border"></div>
{/* 우측: 편집 영역 */}
<div className="flex flex-1 flex-col gap-4">
{!currentRule ? (
<div className="flex h-full flex-col items-center justify-center">
<div className="text-center">
<p className="mb-2 text-lg font-medium text-muted-foreground">
</p>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between">
{editingRightTitle ? (
<Input
value={rightTitle}
onChange={(e) => setRightTitle(e.target.value)}
onBlur={() => setEditingRightTitle(false)}
onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)}
className="h-8 text-sm font-semibold"
autoFocus
/>
) : (
<h2 className="text-sm font-semibold sm:text-base">{rightTitle}</h2>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setEditingRightTitle(true)}
>
<Edit2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<Input
value={currentRule.ruleName}
onChange={(e) =>
setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))
}
className="h-9"
placeholder="예: 프로젝트 코드"
/>
</div>
<Card className="border-border bg-card">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader>
<CardContent>
<NumberingRulePreview config={currentRule} />
</CardContent>
</Card>
<div className="flex-1 overflow-y-auto">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<span className="text-xs text-muted-foreground">
{currentRule.parts.length}/{maxRules}
</span>
</div>
{currentRule.parts.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border bg-muted/50">
<p className="text-xs text-muted-foreground sm:text-sm">
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{currentRule.parts.map((part) => (
<NumberingRuleCard
key={part.id}
part={part}
onUpdate={(updates) => handleUpdatePart(part.id, updates)}
onDelete={() => handleDeletePart(part.id)}
isPreview={isPreview}
/>
))}
</div>
)}
</div>
<div className="flex gap-2">
<Button
onClick={handleAddPart}
disabled={currentRule.parts.length >= maxRules || isPreview || loading}
variant="outline"
className="h-9 flex-1 text-sm"
>
<Plus className="mr-2 h-4 w-4" />
</Button>
<Button
onClick={handleSave}
disabled={isPreview || loading}
className="h-9 flex-1 text-sm"
>
<Save className="mr-2 h-4 w-4" />
{loading ? "저장 중..." : "저장"}
</Button>
</div>
</>
)}
</div>
</div>
);
};

View File

@@ -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<NumberingRulePreviewProps> = ({
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 (
<div className="rounded-md bg-muted px-2 py-1">
<code className="text-xs font-mono text-foreground">{generatedCode}</code>
</div>
);
}
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground sm:text-sm"> </p>
<div className="rounded-md bg-muted p-3 sm:p-4">
<code className="text-sm font-mono text-foreground sm:text-base">{generatedCode}</code>
</div>
</div>
);
};

View File

@@ -0,0 +1,81 @@
/**
* 채번 규칙 관리 API 클라이언트
*/
import { apiClient } from "./client";
import { NumberingRuleConfig } from "@/types/numbering-rule";
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
export async function getNumberingRules(): Promise<ApiResponse<NumberingRuleConfig[]>> {
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<ApiResponse<NumberingRuleConfig>> {
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<ApiResponse<NumberingRuleConfig>> {
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<NumberingRuleConfig>
): Promise<ApiResponse<NumberingRuleConfig>> {
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<ApiResponse<void>> {
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<ApiResponse<{ code: string }>> {
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<ApiResponse<void>> {
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/reset`);
return response.data;
} catch (error: any) {
return { success: false, error: error.message || "시퀀스 초기화 실패" };
}
}

View File

@@ -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";
/**
* 컴포넌트 초기화 함수

View File

@@ -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<NumberingRuleWrapperProps> = ({
config,
onChange,
isPreview = false,
}) => {
return (
<div className="h-full w-full">
<NumberingRuleDesigner
maxRules={config.maxRules || 6}
isPreview={isPreview}
className="h-full"
/>
</div>
);
};
export const NumberingRuleComponent = NumberingRuleWrapper;

View File

@@ -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<NumberingRuleConfigPanelProps> = ({
config,
onChange,
}) => {
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<Input
type="number"
min={1}
max={10}
value={config.maxRules || 6}
onChange={(e) =>
onChange({ ...config, maxRules: parseInt(e.target.value) || 6 })
}
className="h-9"
/>
<p className="text-xs text-muted-foreground">
(1-10)
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) =>
onChange({ ...config, readonly: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={config.showPreview !== false}
onCheckedChange={(checked) =>
onChange({ ...config, showPreview: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Switch
checked={config.showRuleList !== false}
onCheckedChange={(checked) =>
onChange({ ...config, showRuleList: checked })
}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<Select
value={config.cardLayout || "vertical"}
onValueChange={(value: "vertical" | "horizontal") =>
onChange({ ...config, cardLayout: value })
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="vertical"></SelectItem>
<SelectItem value="horizontal"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
);
};

View File

@@ -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 <NumberingRuleComponent {...this.props} renderer={this} />;
}
/**
* 채번 규칙 컴포넌트 특화 메서드
*/
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
}
// 자동 등록 실행
NumberingRuleRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
NumberingRuleRenderer.enableHotReload();
}

View File

@@ -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
<NumberingRuleDesigner
maxRules={6}
isPreview={false}
className="h-full"
/>
```
## 데이터베이스 구조
### 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
- **작성자**: 개발팀

View File

@@ -0,0 +1,15 @@
/**
* 채번 규칙 컴포넌트 기본 설정
*/
import { NumberingRuleComponentConfig } from "./types";
export const defaultConfig: NumberingRuleComponentConfig = {
maxRules: 6,
readonly: false,
showPreview: true,
showRuleList: true,
enableReorder: false,
cardLayout: "vertical",
};

View File

@@ -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";

View File

@@ -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";
}

View File

@@ -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: "연별 초기화" },
];