전체 카테고리 키 목록 조회 API 및 관련 기능 추가
- 카테고리 트리 컨트롤러에 전체 카테고리 키 목록 조회 라우트 추가: GET /api/category-tree/test/all-category-keys - 카테고리 트리 서비스에 전체 카테고리 키 목록 조회 메서드 구현: 모든 테이블과 컬럼 조합을 반환 - 채번규칙 컨트롤러에서 폼 데이터 처리 기능 추가: 코드 미리보기 시 카테고리 기반 폼 데이터 사용 - 관련 API 클라이언트 및 타입 정의 업데이트: 카테고리 키 조회 및 채번규칙 API에 대한 요청 처리 개선 이로 인해 카테고리 관리 및 채번규칙 테스트의 효율성이 향상되었습니다.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || "전체 카테고리 키 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || "테이블.컬럼별 규칙 목록 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }> = [
|
||||
|
||||
Reference in New Issue
Block a user