전체 카테고리 키 목록 조회 API 및 관련 기능 추가

- 카테고리 트리 컨트롤러에 전체 카테고리 키 목록 조회 라우트 추가: GET /api/category-tree/test/all-category-keys
- 카테고리 트리 서비스에 전체 카테고리 키 목록 조회 메서드 구현: 모든 테이블과 컬럼 조합을 반환
- 채번규칙 컨트롤러에서 폼 데이터 처리 기능 추가: 코드 미리보기 시 카테고리 기반 폼 데이터 사용
- 관련 API 클라이언트 및 타입 정의 업데이트: 카테고리 키 조회 및 채번규칙 API에 대한 요청 처리 개선

이로 인해 카테고리 관리 및 채번규칙 테스트의 효율성이 향상되었습니다.
This commit is contained in:
kjs
2026-01-21 17:51:59 +09:00
parent ae4e21e1ac
commit 623ade4f28
14 changed files with 1601 additions and 138 deletions

View File

@@ -16,6 +16,31 @@ interface AuthenticatedRequest extends Request {
};
}
/**
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
* GET /api/category-tree/test/all-category-keys
* 주의: 이 라우트는 /test/:tableName/:columnName 보다 먼저 정의되어야 함
*/
router.get("/test/all-category-keys", async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user?.companyCode || "*";
const keys = await categoryTreeService.getAllCategoryKeys(companyCode);
res.json({
success: true,
data: keys,
});
} catch (error: unknown) {
const err = error as Error;
logger.error("전체 카테고리 키 목록 조회 API 오류", { error: err.message });
res.status(500).json({
success: false,
error: err.message,
});
}
});
/**
* 카테고리 트리 조회
* GET /api/category-tree/test/:tableName/:columnName

View File

@@ -202,9 +202,10 @@ router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, r
router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
const { formData } = req.body; // 폼 데이터 (카테고리 기반 채번 시 사용)
try {
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode);
const previewCode = await numberingRuleService.previewCode(ruleId, companyCode, formData);
return res.json({ success: true, data: { generatedCode: previewCode } });
} catch (error: any) {
logger.error("코드 미리보기 실패", { error: error.message });
@@ -321,4 +322,90 @@ router.post("/test/save", authenticateToken, async (req: AuthenticatedRequest, r
}
});
// [테스트] 테스트 테이블에서 채번규칙 삭제
router.delete("/test/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { ruleId } = req.params;
try {
await numberingRuleService.deleteRuleFromTest(ruleId, companyCode);
return res.json({ success: true });
} catch (error: any) {
logger.error("테스트 테이블에서 채번규칙 삭제 실패", {
error: error.message,
companyCode,
ruleId,
});
return res.status(500).json({ success: false, error: error.message });
}
});
// [테스트] 카테고리 조건 포함 채번규칙 조회
router.get("/test/by-column-with-category", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName, columnName, categoryColumn, categoryValueId } = req.query;
try {
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({ success: false, error: "tableName is required" });
}
if (!columnName || typeof columnName !== "string") {
return res.status(400).json({ success: false, error: "columnName is required" });
}
const rule = await numberingRuleService.getNumberingRuleByColumnWithCategory(
companyCode,
tableName,
columnName,
categoryColumn as string | undefined,
categoryValueId ? Number(categoryValueId) : undefined
);
if (!rule) {
return res.status(404).json({ success: false, error: "규칙을 찾을 수 없습니다" });
}
return res.json({ success: true, data: rule });
} catch (error: any) {
logger.error("카테고리 조건 포함 채번규칙 조회 실패", {
error: error.message,
companyCode,
tableName,
columnName,
});
return res.status(500).json({ success: false, error: error.message });
}
});
// [테스트] 테이블.컬럼별 모든 채번규칙 조회 (카테고리 조건별)
router.get("/test/rules-by-table-column", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.query;
try {
if (!tableName || typeof tableName !== "string") {
return res.status(400).json({ success: false, error: "tableName is required" });
}
if (!columnName || typeof columnName !== "string") {
return res.status(400).json({ success: false, error: "columnName is required" });
}
const rules = await numberingRuleService.getRulesByTableColumn(
companyCode,
tableName,
columnName
);
return res.json({ success: true, data: rules });
} catch (error: any) {
logger.error("테이블.컬럼별 채번규칙 목록 조회 실패", {
error: error.message,
companyCode,
tableName,
columnName,
});
return res.status(500).json({ success: false, error: error.message });
}
});
export default router;

View File

@@ -507,6 +507,39 @@ class CategoryTreeService {
throw error;
}
}
/**
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
* category_values_test 테이블에서 고유한 table_name, column_name 조합을 조회
* 라벨 정보도 함께 반환
*/
async getAllCategoryKeys(companyCode: string): Promise<{ tableName: string; columnName: string; tableLabel: string; columnLabel: string }[]> {
logger.info("getAllCategoryKeys 호출", { companyCode });
const pool = getPool();
try {
const query = `
SELECT DISTINCT
cv.table_name AS "tableName",
cv.column_name AS "columnName",
COALESCE(tl.table_label, cv.table_name) AS "tableLabel",
COALESCE(cl.column_label, cv.column_name) AS "columnLabel"
FROM category_values_test cv
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
LEFT JOIN column_labels cl ON cl.table_name = cv.table_name AND cl.column_name = cv.column_name
WHERE cv.company_code = $1 OR cv.company_code = '*'
ORDER BY cv.table_name, cv.column_name
`;
const result = await pool.query(query, [companyCode]);
logger.info("전체 카테고리 키 목록 조회 완료", { count: result.rows.length });
return result.rows;
} catch (error: unknown) {
const err = error as Error;
logger.error("전체 카테고리 키 목록 조회 실패", { error: err.message });
throw error;
}
}
}
export const categoryTreeService = new CategoryTreeService();

View File

@@ -29,6 +29,10 @@ interface NumberingRuleConfig {
companyCode?: string;
menuObjid?: number;
scopeType?: string;
// 카테고리 조건
categoryColumn?: string;
categoryValueId?: number;
categoryValueLabel?: string; // 조회 시 조인해서 가져옴
createdAt?: string;
updatedAt?: string;
createdBy?: string;
@@ -882,8 +886,15 @@ class NumberingRuleService {
/**
* 코드 미리보기 (순번 증가 없음)
* @param ruleId 채번 규칙 ID
* @param companyCode 회사 코드
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
*/
async previewCode(ruleId: string, companyCode: string): Promise<string> {
async previewCode(
ruleId: string,
companyCode: string,
formData?: Record<string, any>
): Promise<string> {
const rule = await this.getRuleById(ruleId, companyCode);
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
@@ -891,7 +902,8 @@ class NumberingRuleService {
.sort((a: any, b: any) => a.order - b.order)
.map((part: any) => {
if (part.generationMethod === "manual") {
return part.manualConfig?.value || "";
// 수동 입력 - 플레이스홀더 표시 (실제 값은 사용자가 입력)
return part.manualConfig?.placeholder || "____";
}
const autoConfig = part.autoConfig || {};
@@ -913,10 +925,23 @@ class NumberingRuleService {
case "date": {
// 날짜 (다양한 날짜 형식)
return this.formatDate(
new Date(),
autoConfig.dateFormat || "YYYYMMDD"
);
const dateFormat = autoConfig.dateFormat || "YYYYMMDD";
// 컬럼 기준 생성인 경우 폼 데이터에서 날짜 추출
if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) {
const columnValue = formData[autoConfig.sourceColumnName];
if (columnValue) {
const dateValue = columnValue instanceof Date
? columnValue
: new Date(columnValue);
if (!isNaN(dateValue.getTime())) {
return this.formatDate(dateValue, dateFormat);
}
}
}
return this.formatDate(new Date(), dateFormat);
}
case "text": {
@@ -924,6 +949,71 @@ class NumberingRuleService {
return autoConfig.textValue || "TEXT";
}
case "category": {
// 카테고리 기반 코드 생성
const categoryKey = autoConfig.categoryKey; // 예: "item_info.material"
const categoryMappings = autoConfig.categoryMappings || [];
if (!categoryKey || !formData) {
logger.warn("카테고리 키 또는 폼 데이터 없음", { categoryKey, hasFormData: !!formData });
return "";
}
// categoryKey에서 컬럼명 추출 (예: "item_info.material" -> "material")
const columnName = categoryKey.includes(".")
? categoryKey.split(".")[1]
: categoryKey;
// 폼 데이터에서 해당 컬럼의 값 가져오기
const selectedValue = formData[columnName];
logger.info("카테고리 파트 처리", {
categoryKey,
columnName,
selectedValue,
formDataKeys: Object.keys(formData),
mappingsCount: categoryMappings.length
});
if (!selectedValue) {
logger.warn("카테고리 값이 선택되지 않음", { columnName, formDataKeys: Object.keys(formData) });
return "";
}
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
// selectedValue는 valueCode일 수 있음 (UnifiedSelect에서 valueCode를 value로 사용)
const selectedValueStr = String(selectedValue);
const mapping = categoryMappings.find(
(m: any) => {
// ID로 매칭
if (m.categoryValueId?.toString() === selectedValueStr) return true;
// 라벨로 매칭
if (m.categoryValueLabel === selectedValueStr) return true;
// valueCode로 매칭 (라벨과 동일할 수 있음)
if (m.categoryValueLabel === selectedValueStr) return true;
return false;
}
);
if (mapping) {
logger.info("카테고리 매핑 적용", {
selectedValue,
format: mapping.format,
categoryValueLabel: mapping.categoryValueLabel
});
return mapping.format || "";
}
logger.warn("카테고리 매핑을 찾을 수 없음", {
selectedValue,
availableMappings: categoryMappings.map((m: any) => ({
id: m.categoryValueId,
label: m.categoryValueLabel
}))
});
return "";
}
default:
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
return "";
@@ -931,7 +1021,7 @@ class NumberingRuleService {
});
const previewCode = parts.join(rule.separator || "");
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode });
logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode, hasFormData: !!formData });
return previewCode;
}
@@ -1119,22 +1209,27 @@ class NumberingRuleService {
const pool = getPool();
const query = `
SELECT
rule_id AS "ruleId",
rule_name AS "ruleName",
description,
separator,
reset_period AS "resetPeriod",
current_sequence AS "currentSequence",
table_name AS "tableName",
column_name AS "columnName",
company_code AS "companyCode",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy"
FROM numbering_rules_test
WHERE company_code = $1
AND table_name = $2
AND column_name = $3
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
cv.value_label AS "categoryValueLabel",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
AND r.category_value_id IS NULL
LIMIT 1
`;
const params = [companyCode, tableName, columnName];
@@ -1225,8 +1320,10 @@ class NumberingRuleService {
reset_period = $4,
table_name = $5,
column_name = $6,
category_column = $7,
category_value_id = $8,
updated_at = NOW()
WHERE rule_id = $7 AND company_code = $8
WHERE rule_id = $9 AND company_code = $10
`;
await client.query(updateQuery, [
config.ruleName,
@@ -1235,6 +1332,8 @@ class NumberingRuleService {
config.resetPeriod || "none",
config.tableName || "",
config.columnName || "",
config.categoryColumn || null,
config.categoryValueId || null,
config.ruleId,
companyCode,
]);
@@ -1250,8 +1349,9 @@ class NumberingRuleService {
INSERT INTO numbering_rules_test (
rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code,
category_column, category_value_id,
created_at, updated_at, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW(), $12)
`;
await client.query(insertQuery, [
config.ruleId,
@@ -1263,6 +1363,8 @@ class NumberingRuleService {
config.tableName || "",
config.columnName || "",
companyCode,
config.categoryColumn || null,
config.categoryValueId || null,
createdBy,
]);
}
@@ -1309,6 +1411,266 @@ class NumberingRuleService {
client.release();
}
}
/**
* [테스트] 테스트 테이블에서 채번규칙 삭제
* numbering_rules_test 테이블 사용
*/
async deleteRuleFromTest(ruleId: string, companyCode: string): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
logger.info("테스트 테이블에서 채번 규칙 삭제 시작", { ruleId, companyCode });
// 파트 먼저 삭제
await client.query(
"DELETE FROM numbering_rule_parts_test WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
// 규칙 삭제
const result = await client.query(
"DELETE FROM numbering_rules_test WHERE rule_id = $1 AND company_code = $2",
[ruleId, companyCode]
);
await client.query("COMMIT");
logger.info("테스트 테이블에서 채번 규칙 삭제 완료", {
ruleId,
companyCode,
deletedCount: result.rowCount,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("테스트 테이블에서 채번 규칙 삭제 실패", {
error: error.message,
ruleId,
companyCode,
});
throw error;
} finally {
client.release();
}
}
/**
* [테스트] 카테고리 값에 따라 적절한 채번규칙 조회
* 1. 해당 카테고리 값에 매칭되는 규칙 찾기
* 2. 없으면 기본 규칙(category_value_id가 NULL인) 찾기
*/
async getNumberingRuleByColumnWithCategory(
companyCode: string,
tableName: string,
columnName: string,
categoryColumn?: string,
categoryValueId?: number
): Promise<NumberingRuleConfig | null> {
try {
logger.info("카테고리 조건 포함 채번 규칙 조회 시작", {
companyCode,
tableName,
columnName,
categoryColumn,
categoryValueId,
});
const pool = getPool();
// 1. 카테고리 값에 매칭되는 규칙 찾기
if (categoryColumn && categoryValueId) {
const categoryQuery = `
SELECT
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
cv.value_label AS "categoryValueLabel",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
AND r.category_column = $4
AND r.category_value_id = $5
LIMIT 1
`;
const categoryResult = await pool.query(categoryQuery, [
companyCode,
tableName,
columnName,
categoryColumn,
categoryValueId,
]);
if (categoryResult.rows.length > 0) {
const rule = categoryResult.rows[0];
// 파트 정보 조회
const partsQuery = `
SELECT
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
rule.parts = partsResult.rows;
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
ruleId: rule.ruleId,
categoryValueLabel: rule.categoryValueLabel,
});
return rule;
}
}
// 2. 기본 규칙 찾기 (category_value_id가 NULL인)
const defaultQuery = `
SELECT
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
AND r.category_value_id IS NULL
LIMIT 1
`;
const defaultResult = await pool.query(defaultQuery, [companyCode, tableName, columnName]);
if (defaultResult.rows.length > 0) {
const rule = defaultResult.rows[0];
// 파트 정보 조회
const partsQuery = `
SELECT
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
rule.parts = partsResult.rows;
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
ruleId: rule.ruleId,
});
return rule;
}
logger.info("채번 규칙을 찾을 수 없음", {
companyCode,
tableName,
columnName,
categoryColumn,
categoryValueId,
});
return null;
} catch (error: any) {
logger.error("카테고리 조건 포함 채번 규칙 조회 실패", {
error: error.message,
stack: error.stack,
});
throw error;
}
}
/**
* [테스트] 특정 테이블.컬럼의 모든 채번 규칙 조회 (카테고리 조건별)
*/
async getRulesByTableColumn(
companyCode: string,
tableName: string,
columnName: string
): Promise<NumberingRuleConfig[]> {
try {
const pool = getPool();
const query = `
SELECT
r.rule_id AS "ruleId",
r.rule_name AS "ruleName",
r.description,
r.separator,
r.reset_period AS "resetPeriod",
r.current_sequence AS "currentSequence",
r.table_name AS "tableName",
r.column_name AS "columnName",
r.company_code AS "companyCode",
r.category_column AS "categoryColumn",
r.category_value_id AS "categoryValueId",
cv.value_label AS "categoryValueLabel",
r.created_at AS "createdAt",
r.updated_at AS "updatedAt",
r.created_by AS "createdBy"
FROM numbering_rules_test r
LEFT JOIN category_values_test cv ON r.category_value_id = cv.value_id
WHERE r.company_code = $1
AND r.table_name = $2
AND r.column_name = $3
ORDER BY r.category_value_id NULLS FIRST, r.created_at
`;
const result = await pool.query(query, [companyCode, tableName, columnName]);
// 각 규칙의 파트 정보 조회
for (const rule of result.rows) {
const partsQuery = `
SELECT
id,
part_order AS "order",
part_type AS "partType",
generation_method AS "generationMethod",
auto_config AS "autoConfig",
manual_config AS "manualConfig"
FROM numbering_rule_parts_test
WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order
`;
const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]);
rule.parts = partsResult.rows;
}
return result.rows;
} catch (error: any) {
logger.error("테이블.컬럼별 채번 규칙 목록 조회 실패", {
error: error.message,
});
throw error;
}
}
}
export const numberingRuleService = new NumberingRuleService();

View File

@@ -207,48 +207,27 @@ class TableCategoryValueService {
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
NULL::numeric AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
FROM category_values_test
WHERE table_name = $1
AND column_name = $2
`;
// category_values_test 테이블 사용 (menu_objid 없음)
if (companyCode === "*") {
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 조회
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
params = [tableName, columnName, siblingObjids];
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND menu_objid = $3`;
params = [tableName, columnName, menuObjid];
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
} else {
// menuObjid 없으면 모든 값 조회 (중복 가능)
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
}
// 최고 관리자: 모든 값 조회
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (category_values_test)");
} else {
// 일반 회사: 자신의 회사 + menuObjid로 필터링
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
params = [tableName, columnName, companyCode, siblingObjids];
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
params = [tableName, columnName, companyCode, menuObjid];
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
} else {
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
query = baseSelect + ` AND company_code = $3`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
}
// 일반 회사: 자신의 회사 또는 공통(*) 카테고리 조회
query = baseSelect + ` AND (company_code = $3 OR company_code = '*')`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (category_values_test)", { companyCode });
}
if (!includeInactive) {

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -150,6 +150,17 @@ export const AutoConfigPanel: React.FC<AutoConfigPanelProps> = ({
);
}
// 5. 카테고리
if (partType === "category") {
return (
<CategoryConfigPanel
config={config}
onChange={onChange}
isPreview={isPreview}
/>
);
}
return null;
};
@@ -463,3 +474,615 @@ const DateConfigPanel: React.FC<DateConfigPanelProps> = ({
</div>
);
};
/**
* 카테고리 타입 전용 설정 패널
* - 카테고리 선택 (테이블.컬럼)
* - 카테고리 값별 형식 매핑
*/
import { CategoryFormatMapping } from "@/types/numbering-rule";
import { Plus, Trash2, FolderTree } from "lucide-react";
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
interface CategoryValueNode {
valueId: number;
valueCode: string;
valueLabel: string;
depth: number;
path: string;
parentValueId: number | null;
children?: CategoryValueNode[];
}
interface CategoryConfigPanelProps {
config?: {
categoryKey?: string;
categoryMappings?: CategoryFormatMapping[];
};
onChange: (config: any) => void;
isPreview?: boolean;
}
const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
config = {},
onChange,
isPreview = false,
}) => {
// 카테고리 옵션 (테이블.컬럼 + 라벨)
const [categoryOptions, setCategoryOptions] = useState<{
tableName: string;
columnName: string;
displayName: string;
displayLabel: string; // 라벨 (테이블라벨.컬럼라벨)
}[]>([]);
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
// 카테고리 값 트리
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
const [loadingValues, setLoadingValues] = useState(false);
// 계층적 선택 상태 (대분류, 중분류, 소분류)
const [level1Id, setLevel1Id] = useState<number | null>(null);
const [level2Id, setLevel2Id] = useState<number | null>(null);
const [level3Id, setLevel3Id] = useState<number | null>(null);
const [level1Open, setLevel1Open] = useState(false);
const [level2Open, setLevel2Open] = useState(false);
const [level3Open, setLevel3Open] = useState(false);
// 형식 입력
const [newFormat, setNewFormat] = useState("");
// 수정 모드
const [editingId, setEditingId] = useState<number | null>(null);
// 수정 모드 진입 중 플래그 (useEffect 초기화 방지)
const isEditingRef = useRef(false);
const categoryKey = config.categoryKey || "";
const mappings = config.categoryMappings || [];
// 이미 추가된 카테고리 ID 목록 (수정 중인 항목 제외)
const addedValueIds = useMemo(() => {
return mappings
.filter(m => m.categoryValueId !== editingId)
.map(m => m.categoryValueId);
}, [mappings, editingId]);
// 카테고리 옵션 로드
useEffect(() => {
loadCategoryOptions();
}, []);
// 카테고리 키 변경 시 값 로드 및 선택 초기화
useEffect(() => {
if (categoryKey) {
const [tableName, columnName] = categoryKey.split(".");
if (tableName && columnName) {
loadCategoryValues(tableName, columnName);
}
} else {
setCategoryValues([]);
}
// 선택 초기화
setLevel1Id(null);
setLevel2Id(null);
setLevel3Id(null);
}, [categoryKey]);
// 대분류 변경 시 중분류/소분류 초기화 (수정 모드 진입 중에는 건너뜀)
useEffect(() => {
if (isEditingRef.current) return;
setLevel2Id(null);
setLevel3Id(null);
}, [level1Id]);
// 중분류 변경 시 소분류 초기화 (수정 모드 진입 중에는 건너뜀)
useEffect(() => {
if (isEditingRef.current) return;
setLevel3Id(null);
}, [level2Id]);
const loadCategoryOptions = async () => {
try {
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options = response.data.map((item: { tableName: string; columnName: string; tableLabel?: string; columnLabel?: string }) => ({
tableName: item.tableName,
columnName: item.columnName,
displayName: `${item.tableName}.${item.columnName}`,
displayLabel: `${item.tableLabel || item.tableName}.${item.columnLabel || item.columnName}`,
}));
setCategoryOptions(options);
}
} catch (error) {
console.error("카테고리 옵션 로드 실패:", error);
}
};
const loadCategoryValues = async (tableName: string, columnName: string) => {
console.log("loadCategoryValues 호출:", { tableName, columnName });
setLoadingValues(true);
try {
const response = await getCategoryTree(tableName, columnName);
console.log("getCategoryTree 응답:", response);
if (response.success && response.data) {
console.log("카테고리 트리 로드 성공:", response.data);
setCategoryValues(response.data);
} else {
console.log("카테고리 트리 로드 실패:", response.error);
setCategoryValues([]);
}
} catch (error) {
console.error("카테고리 값 로드 실패:", error);
setCategoryValues([]);
} finally {
setLoadingValues(false);
}
};
// 이미 추가된 항목 확인 (해당 노드 또는 하위 노드가 추가되었는지)
const isNodeOrDescendantAdded = useCallback((node: CategoryValueNode): boolean => {
if (addedValueIds.includes(node.valueId)) return true;
if (node.children?.length) {
return node.children.every(child => isNodeOrDescendantAdded(child));
}
return false;
}, [addedValueIds]);
// 각 레벨별 항목 계산 (이미 추가된 항목 필터링)
const level1Items = useMemo(() => {
return categoryValues.filter(v => !isNodeOrDescendantAdded(v));
}, [categoryValues, isNodeOrDescendantAdded]);
const level2Items = useMemo(() => {
if (!level1Id) return [];
const parent = categoryValues.find(v => v.valueId === level1Id);
const children = parent?.children || [];
return children.filter(v => !isNodeOrDescendantAdded(v));
}, [categoryValues, level1Id, isNodeOrDescendantAdded]);
const level3Items = useMemo(() => {
if (!level2Id) return [];
const parent = categoryValues.find(v => v.valueId === level1Id);
const level2Parent = parent?.children?.find(v => v.valueId === level2Id);
const children = level2Parent?.children || [];
return children.filter(v => !addedValueIds.includes(v.valueId));
}, [categoryValues, level1Id, level2Id, addedValueIds]);
// 선택된 값 정보 계산
const getSelectedInfo = () => {
// 가장 깊은 레벨의 선택된 값
const selectedId = level3Id || level2Id || level1Id;
if (!selectedId) return null;
// 선택된 노드 찾기
const findNode = (nodes: CategoryValueNode[], id: number): CategoryValueNode | null => {
for (const node of nodes) {
if (node.valueId === id) return node;
if (node.children?.length) {
const found = findNode(node.children, id);
if (found) return found;
}
}
return null;
};
const node = findNode(categoryValues, selectedId);
if (!node) return null;
// 경로 생성
const pathParts: string[] = [];
const l1 = categoryValues.find(v => v.valueId === level1Id);
if (l1) pathParts.push(l1.valueLabel);
if (level2Id) {
const l2 = level2Items.find(v => v.valueId === level2Id);
if (l2) pathParts.push(l2.valueLabel);
}
if (level3Id) {
const l3 = level3Items.find(v => v.valueId === level3Id);
if (l3) pathParts.push(l3.valueLabel);
}
return {
valueId: selectedId,
valueLabel: node.valueLabel,
valuePath: pathParts.join(" > "),
};
};
const selectedInfo = getSelectedInfo();
// 매핑 추가/수정
const handleAddMapping = () => {
if (!selectedInfo || !newFormat.trim()) return;
const newMapping: CategoryFormatMapping = {
categoryValueId: selectedInfo.valueId,
categoryValueLabel: selectedInfo.valueLabel,
categoryValuePath: selectedInfo.valuePath,
format: newFormat.trim(),
};
let updatedMappings: CategoryFormatMapping[];
if (editingId !== null) {
// 수정 모드: 기존 항목 교체
updatedMappings = mappings.map(m =>
m.categoryValueId === editingId ? newMapping : m
);
} else {
// 추가 모드: 중복 체크
const exists = mappings.some(m => m.categoryValueId === selectedInfo.valueId);
if (exists) {
alert("이미 추가된 카테고리입니다");
return;
}
updatedMappings = [...mappings, newMapping];
}
onChange({
...config,
categoryMappings: updatedMappings,
});
// 초기화
setLevel1Id(null);
setLevel2Id(null);
setLevel3Id(null);
setNewFormat("");
setEditingId(null);
};
// 매핑 수정 모드 진입
const handleEditMapping = (mapping: CategoryFormatMapping) => {
// useEffect 초기화 방지 플래그 설정
isEditingRef.current = true;
// 해당 카테고리의 경로를 파싱해서 레벨별로 설정
const findParentIds = (nodes: CategoryValueNode[], targetId: number, path: number[] = []): number[] | null => {
for (const node of nodes) {
if (node.valueId === targetId) {
return path;
}
if (node.children?.length) {
const result = findParentIds(node.children, targetId, [...path, node.valueId]);
if (result) return result;
}
}
return null;
};
const parentPath = findParentIds(categoryValues, mapping.categoryValueId);
if (parentPath && parentPath.length > 0) {
setLevel1Id(parentPath[0] || null);
if (parentPath.length === 2) {
// 3단계: 대분류 > 중분류 > 소분류
setLevel2Id(parentPath[1]);
setLevel3Id(mapping.categoryValueId);
} else if (parentPath.length === 1) {
// 2단계: 대분류 > 중분류
setLevel2Id(mapping.categoryValueId);
setLevel3Id(null);
} else {
setLevel2Id(null);
setLevel3Id(null);
}
} else {
// 루트 레벨 항목 (1단계)
setLevel1Id(mapping.categoryValueId);
setLevel2Id(null);
setLevel3Id(null);
}
setNewFormat(mapping.format);
setEditingId(mapping.categoryValueId);
// 다음 렌더링 사이클에서 플래그 해제
setTimeout(() => {
isEditingRef.current = false;
}, 0);
};
// 수정 취소
const handleCancelEdit = () => {
setLevel1Id(null);
setLevel2Id(null);
setLevel3Id(null);
setNewFormat("");
setEditingId(null);
};
// 매핑 삭제
const handleRemoveMapping = (valueId: number) => {
onChange({
...config,
categoryMappings: mappings.filter(m => m.categoryValueId !== valueId),
});
};
return (
<div className="space-y-4">
{/* 카테고리 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> </Label>
<Popover open={categoryKeyOpen} onOpenChange={setCategoryKeyOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={categoryKeyOpen}
disabled={isPreview}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
<span className="truncate">
{categoryKey
? categoryOptions.find(o => o.displayName === categoryKey)?.displayLabel || categoryKey
: "카테고리 선택"}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<Command>
<CommandInput placeholder="카테고리 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{categoryOptions.map((opt) => (
<CommandItem
key={opt.displayName}
value={opt.displayLabel}
onSelect={() => {
onChange({ ...config, categoryKey: opt.displayName, categoryMappings: [] });
setCategoryKeyOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", categoryKey === opt.displayName ? "opacity-100" : "opacity-0")} />
{opt.displayLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 형식 설정 */}
{categoryKey && (
<div className="space-y-3 border-t pt-3">
<Label className="flex items-center gap-2 text-xs font-medium sm:text-sm">
<FolderTree className="h-4 w-4" />
</Label>
{/* 계층적 선택 UI */}
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{/* 대분류 선택 */}
<div className="min-w-[100px] flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Popover open={level1Open} onOpenChange={setLevel1Open}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPreview || loadingValues || level1Items.length === 0}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{loadingValues ? "로딩..." : level1Items.find(v => v.valueId === level1Id)?.valueLabel || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{level1Items.map((val) => (
<CommandItem
key={val.valueId}
value={val.valueLabel}
onSelect={() => {
setLevel1Id(val.valueId);
setLevel1Open(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", level1Id === val.valueId ? "opacity-100" : "opacity-0")} />
{val.valueLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 중분류 선택 (대분류 선택 후 하위가 있을 때만 표시) */}
{level1Id && level2Items.length > 0 && (
<div className="min-w-[100px] flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Popover open={level2Open} onOpenChange={setLevel2Open}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPreview}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{level2Items.find(v => v.valueId === level2Id)?.valueLabel || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{level2Items.map((val) => (
<CommandItem
key={val.valueId}
value={val.valueLabel}
onSelect={() => {
setLevel2Id(val.valueId);
setLevel2Open(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", level2Id === val.valueId ? "opacity-100" : "opacity-0")} />
{val.valueLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 소분류 선택 (중분류 선택 후 하위가 있을 때만 표시) */}
{level2Id && level3Items.length > 0 && (
<div className="min-w-[100px] flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Popover open={level3Open} onOpenChange={setLevel3Open}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={isPreview}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{level3Items.find(v => v.valueId === level3Id)?.valueLabel || "선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> </CommandEmpty>
<CommandGroup>
{level3Items.map((val) => (
<CommandItem
key={val.valueId}
value={val.valueLabel}
onSelect={() => {
setLevel3Id(val.valueId);
setLevel3Open(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", level3Id === val.valueId ? "opacity-100" : "opacity-0")} />
{val.valueLabel}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
{/* 형식 입력 + 추가/수정 버튼 */}
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={newFormat}
onChange={(e) => setNewFormat(e.target.value.toUpperCase())}
placeholder="예: ITM, VLV, PIP"
disabled={isPreview || !selectedInfo}
className="h-8 text-xs"
maxLength={10}
/>
</div>
<div className="flex items-end gap-1">
{editingId !== null && (
<Button
size="sm"
variant="outline"
onClick={handleCancelEdit}
className="h-8 text-xs"
>
</Button>
)}
<Button
size="sm"
onClick={handleAddMapping}
disabled={isPreview || !selectedInfo || !newFormat.trim()}
className="h-8"
>
{editingId !== null ? <Check className="h-4 w-4" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
</div>
{/* 선택된 경로 표시 */}
{selectedInfo && (
<p className="text-[10px] text-muted-foreground">
{editingId !== null ? "수정 중: " : "선택: "}{selectedInfo.valuePath}
</p>
)}
</div>
{/* 추가된 매핑 목록 */}
{mappings.length > 0 && (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> ( )</Label>
<div className="space-y-1">
{mappings.map((m) => (
<div
key={m.categoryValueId}
className={cn(
"flex cursor-pointer items-center justify-between rounded px-2 py-1 transition-colors hover:bg-muted",
editingId === m.categoryValueId ? "bg-primary/10 ring-1 ring-primary" : "bg-muted/50"
)}
onClick={() => !isPreview && handleEditMapping(m)}
>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{m.categoryValuePath || m.categoryValueLabel}</span>
<span></span>
<span className="font-mono font-medium">{m.format}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
handleRemoveMapping(m.categoryValueId);
}}
disabled={isPreview}
className="h-5 w-5"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
</div>
)}
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
</div>
);
};

View File

@@ -9,7 +9,7 @@ interface ManualConfigPanelProps {
value?: string;
placeholder?: string;
};
onChange: (config: any) => void;
onChange: (config: { value?: string; placeholder?: string }) => void;
isPreview?: boolean;
}
@@ -20,17 +20,9 @@ export const ManualConfigPanel: React.FC<ManualConfigPanelProps> = ({
}) => {
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">
<div className="rounded-lg border border-dashed border-muted-foreground/50 bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">
</p>
</div>
<div>
@@ -42,6 +34,9 @@ export const ManualConfigPanel: React.FC<ManualConfigPanelProps> = ({
disabled={isPreview}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
</div>
);

View File

@@ -56,6 +56,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
number: { numberLength: 4, numberValue: 1 },
date: { dateFormat: "YYYYMMDD" },
text: { textValue: "CODE" },
category: { categoryKey: "", categoryMappings: [] },
};
onUpdate({
partType: newPartType,

View File

@@ -6,17 +6,31 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Save, Edit2, Trash2 } from "lucide-react";
import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react";
import { toast } from "sonner";
import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
getAvailableNumberingRules,
createNumberingRule,
updateNumberingRule,
deleteNumberingRule,
saveNumberingRuleToTest,
deleteNumberingRuleFromTest,
} from "@/lib/api/numberingRule";
import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
// 카테고리 값 트리 노드 타입
interface CategoryValueNode {
valueId: number;
valueCode: string;
valueLabel: string;
depth: number;
path: string;
parentValueId: number | null;
children?: CategoryValueNode[];
}
interface NumberingRuleDesignerProps {
initialConfig?: NumberingRuleConfig;
@@ -52,10 +66,96 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
const [customSeparator, setCustomSeparator] = useState("");
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
interface CategoryOption {
tableName: string;
columnName: string;
displayName: string; // "테이블명.컬럼명" 형식
}
const [allCategoryOptions, setAllCategoryOptions] = useState<CategoryOption[]>([]);
const [selectedCategoryKey, setSelectedCategoryKey] = useState<string>(""); // "tableName.columnName"
const [categoryValues, setCategoryValues] = useState<CategoryValueNode[]>([]);
const [categoryKeyOpen, setCategoryKeyOpen] = useState(false);
const [categoryValueOpen, setCategoryValueOpen] = useState(false);
const [loadingCategories, setLoadingCategories] = useState(false);
useEffect(() => {
loadRules();
loadAllCategoryOptions(); // 전체 카테고리 옵션 로드
}, []);
// currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화
useEffect(() => {
if (currentRule?.categoryColumn) {
setSelectedCategoryKey(currentRule.categoryColumn);
} else {
setSelectedCategoryKey("");
}
}, [currentRule?.categoryColumn]);
// 카테고리 키 선택 시 해당 카테고리 값 로드
useEffect(() => {
if (selectedCategoryKey) {
const [tableName, columnName] = selectedCategoryKey.split(".");
if (tableName && columnName) {
loadCategoryValues(tableName, columnName);
}
} else {
setCategoryValues([]);
}
}, [selectedCategoryKey]);
// 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼)
const loadAllCategoryOptions = async () => {
try {
// category_values_test 테이블에서 고유한 테이블.컬럼 조합 조회
const response = await getAllCategoryKeys();
if (response.success && response.data) {
const options: CategoryOption[] = response.data.map((item) => ({
tableName: item.tableName,
columnName: item.columnName,
displayName: `${item.tableName}.${item.columnName}`,
}));
setAllCategoryOptions(options);
console.log("전체 카테고리 옵션 로드:", options);
}
} catch (error) {
console.error("카테고리 옵션 목록 조회 실패:", error);
}
};
// 특정 카테고리 컬럼의 값 트리 조회
const loadCategoryValues = async (tableName: string, columnName: string) => {
setLoadingCategories(true);
try {
const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) {
setCategoryValues(response.data);
console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length });
} else {
setCategoryValues([]);
}
} catch (error) {
console.error("카테고리 값 트리 조회 실패:", error);
setCategoryValues([]);
} finally {
setLoadingCategories(false);
}
};
// 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용)
const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => {
for (const node of nodes) {
result.push(node);
if (node.children && node.children.length > 0) {
flattenCategoryValues(node.children, result);
}
}
return result;
};
const flatCategoryValues = flattenCategoryValues(categoryValues);
const loadRules = useCallback(async () => {
setLoading(true);
try {
@@ -237,12 +337,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
ruleToSave,
});
let response;
if (existing) {
response = await updateNumberingRule(ruleToSave.ruleId, ruleToSave);
} else {
response = await createNumberingRule(ruleToSave);
}
// 테스트 테이블에 저장 (numbering_rules_test)
const response = await saveNumberingRuleToTest(ruleToSave);
if (response.success && response.data) {
setSavedRules((prev) => {
@@ -278,7 +374,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
async (ruleId: string) => {
setLoading(true);
try {
const response = await deleteNumberingRule(ruleId);
const response = await deleteNumberingRuleFromTest(ruleId);
if (response.success) {
setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId));

View File

@@ -429,6 +429,35 @@ export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>((props
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoGeneration.enabled, autoGeneration.type, isEditMode, formDataForNumbering]);
// 채번 규칙 ID 캐싱
const numberingRuleIdRef = useRef<string | null>(null);
const lastCategoryValuesRef = useRef<string>("");
// 사용자가 직접 입력 중인지 추적 (재생성 방지)
const userEditedNumberingRef = useRef<boolean>(false);
// 원래 수동 입력 부분이 있었는지 추적 (____가 있었으면 계속 편집 가능)
const hadManualPartRef = useRef<boolean>(false);
// 채번 템플릿 저장 (____가 포함된 원본 형태)
const numberingTemplateRef = useRef<string>("");
// 사용자가 수동 입력한 값 저장
const [manualInputValue, setManualInputValue] = useState<string>("");
// formData에서 카테고리 관련 값 추출 (채번 파트에서 카테고리 사용 시)
// 채번 필드 자체의 값은 제외해야 함 (무한 루프 방지)
const categoryValuesForNumbering = useMemo(() => {
const inputType = config.inputType || config.type || "text";
if (inputType !== "numbering") return "";
// formData에서 category 타입 필드 값들을 추출 (채번 필드 자체는 제외)
const categoryFields: Record<string, string> = {};
for (const [key, val] of Object.entries(formData)) {
// 현재 채번 필드(columnName)는 제외
if (key === columnName) continue;
if (typeof val === "string" && val) {
categoryFields[key] = val;
}
}
return JSON.stringify(categoryFields);
}, [config.inputType, config.type, formData, columnName]);
// 채번 타입 자동생성 로직 (테이블 관리에서 설정된 numberingRuleId 사용)
useEffect(() => {
const generateNumberingCode = async () => {
@@ -439,18 +468,29 @@ export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>((props
return;
}
// 이미 생성되었거나 생성 중이면 스킵
if (hasGeneratedNumberingRef.current || isGeneratingNumbering) {
return;
}
// 수정 모드에서는 자동생성 안함
if (isEditMode) {
return;
}
// 이미 값이 있으면 스킵
if (value !== undefined && value !== null && value !== "") {
// 생성 중이면 스킵
if (isGeneratingNumbering) {
return;
}
// 사용자가 직접 편집한 경우 재생성 안함 (단, 카테고리 변경 시에는 재생성)
const categoryChanged = categoryValuesForNumbering !== lastCategoryValuesRef.current;
if (userEditedNumberingRef.current && !categoryChanged) {
return;
}
// 이미 생성되었고 카테고리 값이 변경되지 않았으면 스킵
if (hasGeneratedNumberingRef.current && !categoryChanged) {
return;
}
// 첫 생성 시: 값이 이미 있고 카테고리 변경이 아니면 스킵
if (!categoryChanged && value !== undefined && value !== null && value !== "") {
return;
}
@@ -463,47 +503,87 @@ export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>((props
setIsGeneratingNumbering(true);
try {
// 테이블 설정에서 numberingRuleId 조회
const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName);
// 채번 규칙 ID 캐싱 (한 번만 조회)
if (!numberingRuleIdRef.current) {
const { getTableColumns } = await import("@/lib/api/tableManagement");
const columnsResponse = await getTableColumns(tableName);
if (!columnsResponse.success || !columnsResponse.data) {
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
return;
}
if (!columnsResponse.success || !columnsResponse.data) {
console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse);
return;
}
const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: any) => col.columnName === columnName);
const columns = columnsResponse.data.columns || columnsResponse.data;
const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName);
if (!targetColumn) {
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
return;
}
if (!targetColumn) {
console.warn("컬럼 정보를 찾을 수 없습니다:", columnName);
return;
}
// detailSettings에서 numberingRuleId 추출
let numberingRuleId: string | undefined;
if (targetColumn.detailSettings && typeof targetColumn.detailSettings === "string") {
try {
const parsed = JSON.parse(targetColumn.detailSettings);
numberingRuleId = parsed.numberingRuleId;
} catch {
// JSON 파싱 실패
// detailSettings에서 numberingRuleId 추출
if (targetColumn.detailSettings && typeof targetColumn.detailSettings === "string") {
try {
const parsed = JSON.parse(targetColumn.detailSettings);
numberingRuleIdRef.current = parsed.numberingRuleId || null;
} catch {
// JSON 파싱 실패
}
}
}
const numberingRuleId = numberingRuleIdRef.current;
if (!numberingRuleId) {
console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName });
return;
}
// 채번 코드 생성 (미리보기)
const previewResponse = await previewNumberingCode(numberingRuleId);
// 채번 코드 생성 (formData 전달하여 카테고리 값 기반 생성)
const previewResponse = await previewNumberingCode(numberingRuleId, formData);
if (previewResponse.success && previewResponse.data?.generatedCode) {
const generatedCode = previewResponse.data.generatedCode;
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
hasGeneratedNumberingRef.current = true;
lastCategoryValuesRef.current = categoryValuesForNumbering;
// 수동 입력 부분이 있는 경우
if (generatedCode.includes("____")) {
hadManualPartRef.current = true;
const oldTemplate = numberingTemplateRef.current;
numberingTemplateRef.current = generatedCode;
// 카테고리 변경으로 템플릿이 바뀌었을 때 기존 사용자 입력값 유지
if (oldTemplate && oldTemplate !== generatedCode) {
// 템플릿이 변경되었지만 사용자 입력값은 유지
const templateParts = generatedCode.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
// 기존 manualInputValue를 사용하여 새 값 조합 (상태는 유지)
// 참고: setManualInputValue는 호출하지 않음 (기존 값 유지)
const finalValue = templatePrefix + (userEditedNumberingRef.current ? "" : "") + templateSuffix;
// 사용자가 입력한 적이 없으면 템플릿 그대로
if (!userEditedNumberingRef.current) {
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
}
// 사용자가 입력한 적이 있으면 입력값 유지하며 템플릿만 변경
// (manualInputValue 상태는 유지되므로 UI에서 자동 반영)
} else {
// 첫 생성
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
userEditedNumberingRef.current = false;
}
} else {
// 수동 입력 부분 없음
setAutoGeneratedValue(generatedCode);
onChange?.(generatedCode);
userEditedNumberingRef.current = false;
}
console.log("채번 코드 생성 성공:", generatedCode);
} else {
console.warn("채번 코드 생성 실패:", previewResponse);
@@ -517,7 +597,7 @@ export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>((props
generateNumberingCode();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, columnName, isEditMode, value]);
}, [tableName, columnName, isEditMode, categoryValuesForNumbering]);
// 실제 표시할 값 (자동생성 값 또는 props value)
const displayValue = autoGeneratedValue ?? value;
@@ -618,17 +698,66 @@ export const UnifiedInput = forwardRef<HTMLDivElement, UnifiedInputProps>((props
/>
);
case "numbering":
// 채번 타입: 읽기 전용 텍스트 필드로 표시 (자동 생성)
case "numbering": {
// 채번 타입: ____ 부분만 편집 가능하게 처리
const template = numberingTemplateRef.current;
const canEdit = hadManualPartRef.current && template;
console.log("채번 필드 렌더링:", { displayValue, template, manualInputValue, canEdit, isGeneratingNumbering });
// 템플릿이 없으면 읽기 전용 (아직 생성 전이거나 수동 입력 부분 없음)
if (!canEdit) {
return (
<TextInput
value={displayValue || ""}
onChange={() => {}}
placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"}
readonly={true}
disabled={disabled || isGeneratingNumbering}
/>
);
}
// 템플릿에서 prefix와 suffix 추출
const templateParts = template.split("____");
const templatePrefix = templateParts[0] || "";
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
return (
<TextInput
value={displayValue || ""}
onChange={() => {}}
placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"}
readonly={true}
disabled={disabled || isGeneratingNumbering}
/>
<div className="flex h-full items-center rounded-md border">
{/* 고정 접두어 */}
{templatePrefix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
{templatePrefix}
</span>
)}
{/* 편집 가능한 부분 */}
<input
type="text"
value={manualInputValue}
onChange={(e) => {
const newUserInput = e.target.value;
setManualInputValue(newUserInput);
// 전체 값 조합
const newValue = templatePrefix + newUserInput + templateSuffix;
userEditedNumberingRef.current = true;
setAutoGeneratedValue(newValue);
onChange?.(newValue);
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
disabled={disabled || isGeneratingNumbering}
/>
{/* 고정 접미어 */}
{templateSuffix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
{templateSuffix}
</span>
)}
</div>
);
}
default:
return (

View File

@@ -454,6 +454,8 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
config: configProp,
value,
onChange,
tableName,
columnName,
} = props;
// config가 없으면 기본값 사용
@@ -464,16 +466,13 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
const [optionsLoaded, setOptionsLoaded] = useState(false);
// 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용)
// category 소스는 code로 자동 변환 (카테고리 → 공통코드 통합)
const rawSource = config.source;
const categoryTable = (config as any).categoryTable;
const categoryColumn = (config as any).categoryColumn;
// category 소스인 경우 code로 변환하고 codeGroup을 자동 생성
const source = rawSource === "category" ? "code" : rawSource;
const codeGroup = rawSource === "category" && categoryTable && categoryColumn
? `${categoryTable.toUpperCase()}_${categoryColumn.toUpperCase()}`
: config.codeGroup;
// category 소스 유지 (category_values_test 테이블에서 로드)
const source = rawSource;
const codeGroup = config.codeGroup;
const entityTable = config.entityTable;
const entityValueColumn = config.entityValueColumn || config.entityValueField;
@@ -590,6 +589,35 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
if (Array.isArray(data)) {
fetchedOptions = data;
}
} else if (source === "category") {
// 카테고리에서 로드 (category_values_test 테이블)
// tableName, columnName은 props에서 가져옴
const catTable = categoryTable || tableName;
const catColumn = categoryColumn || columnName;
if (catTable && catColumn) {
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
const data = response.data;
if (data.success && data.data) {
// 트리 구조를 평탄화하여 옵션으로 변환
// value로 valueId를 사용하여 채번 규칙 매핑과 일치하도록 함
const flattenTree = (items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[], depth: number = 0): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
result.push({
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
label: prefix + item.valueLabel,
});
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
}
}
return result;
};
fetchedOptions = flattenTree(data.data);
}
}
}
setOptions(fetchedOptions);

View File

@@ -189,3 +189,19 @@ export async function getCategoryColumns(
}
}
/**
* 전체 카테고리 키 목록 조회 (모든 테이블.컬럼 조합)
*/
export async function getAllCategoryKeys(): Promise<ApiResponse<{ tableName: string; columnName: string }[]>> {
try {
const response = await apiClient.get("/category-tree/test/all-category-keys");
return response.data;
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string };
return {
success: false,
error: err.response?.data?.error || err.message || "전체 카테고리 키 조회 실패",
};
}
}

View File

@@ -105,9 +105,12 @@ export async function deleteNumberingRule(ruleId: string): Promise<ApiResponse<v
/**
* 코드 미리보기 (순번 증가 없음)
* 화면 표시용으로 사용
* @param ruleId 채번 규칙 ID
* @param formData 폼 데이터 (카테고리 기반 채번 시 사용)
*/
export async function previewNumberingCode(
ruleId: string
ruleId: string,
formData?: Record<string, unknown>
): Promise<ApiResponse<{ generatedCode: string }>> {
// ruleId 유효성 검사
if (!ruleId || ruleId === "undefined" || ruleId === "null") {
@@ -115,16 +118,19 @@ export async function previewNumberingCode(
}
try {
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`);
const response = await apiClient.post(`/numbering-rules/${ruleId}/preview`, {
formData: formData || {},
});
if (!response.data) {
return { success: false, error: "서버 응답이 비어있습니다" };
}
return response.data;
} catch (error: any) {
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string; message?: string } }; message?: string };
const errorMessage =
error.response?.data?.error ||
error.response?.data?.message ||
error.message ||
err.response?.data?.error ||
err.response?.data?.message ||
err.message ||
"코드 미리보기 실패";
return { success: false, error: errorMessage };
}
@@ -208,3 +214,64 @@ export async function saveNumberingRuleToTest(
}
}
/**
* [테스트] 테스트 테이블에서 채번규칙 삭제
* numbering_rules_test 테이블 사용
*/
export async function deleteNumberingRuleFromTest(
ruleId: string
): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/numbering-rules/test/${ruleId}`);
return response.data;
} catch (error: unknown) {
const err = error as { response?: { data?: { error?: string } }; message?: string };
return {
success: false,
error: err.response?.data?.error || err.message || "테스트 규칙 삭제 실패",
};
}
}
/**
* [테스트] 카테고리 조건 포함 채번규칙 조회
*/
export async function getNumberingRuleByColumnWithCategory(
tableName: string,
columnName: string,
categoryColumn?: string,
categoryValueId?: number
): Promise<ApiResponse<NumberingRuleConfig>> {
try {
const response = await apiClient.get("/numbering-rules/test/by-column-with-category", {
params: { tableName, columnName, categoryColumn, categoryValueId },
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || "카테고리 조건 규칙 조회 실패",
};
}
}
/**
* [테스트] 테이블.컬럼별 모든 채번규칙 조회 (카테고리 조건별)
*/
export async function getRulesByTableColumn(
tableName: string,
columnName: string
): Promise<ApiResponse<NumberingRuleConfig[]>> {
try {
const response = await apiClient.get("/numbering-rules/test/rules-by-table-column", {
params: { tableName, columnName },
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || error.message || "테이블.컬럼별 규칙 목록 조회 실패",
};
}
}

View File

@@ -4,13 +4,14 @@
*/
/**
* 코드 파트 유형 (4가지)
* 코드 파트 유형 (5가지)
*/
export type CodePartType =
| "sequence" // 순번 (자동 증가 숫자)
| "number" // 숫자 (고정 자릿수)
| "date" // 날짜 (다양한 날짜 형식)
| "text"; // 문자 (텍스트)
| "text" // 문자 (텍스트)
| "category"; // 카테고리 (카테고리 값에 따른 형식)
/**
* 생성 방식
@@ -30,6 +31,17 @@ export type DateFormat =
| "YYYYMMDD" // 20251104
| "YYMMDD"; // 251104
/**
* 카테고리 값별 형식 매핑
* 예: 가스켓 → ITM, 벌브 → VLV
*/
export interface CategoryFormatMapping {
categoryValueId: number; // 카테고리 값 ID
categoryValueLabel: string; // 카테고리 값 라벨 (표시용)
categoryValuePath?: string; // 전체 경로 (예: "원자재/벌크/가스켓")
format: string; // 생성할 형식 (예: "ITM", "VLV")
}
/**
* 단일 규칙 파트
*/
@@ -57,6 +69,10 @@ export interface NumberingRulePart {
// 문자용
textValue?: string; // 텍스트 값 (예: "PRJ", "CODE")
// 카테고리용
categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type")
categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑
};
// 직접 입력 설정
@@ -91,6 +107,11 @@ export interface NumberingRuleConfig {
tableName?: string; // 적용할 테이블명
columnName?: string; // 적용할 컬럼명
// 카테고리 조건 (특정 카테고리 값일 때만 이 규칙 적용)
categoryColumn?: string; // 카테고리 조건 컬럼명 (예: 'type', 'material')
categoryValueId?: number; // 카테고리 값 ID (category_values_test.value_id)
categoryValueLabel?: string; // 카테고리 값 라벨 (조회 시 조인)
// 메타 정보
companyCode?: string;
createdAt?: string;
@@ -106,6 +127,7 @@ export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string;
{ value: "number", label: "숫자", description: "고정 자릿수 숫자 (001, 002...)" },
{ value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" },
{ value: "text", label: "문자", description: "텍스트 또는 코드" },
{ value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" },
];
export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [