카테고리 트리 기능 추가 및 관련 API 구현
- 카테고리 트리 컨트롤러와 서비스 추가: 트리 구조를 지원하는 카테고리 값 관리 기능을 구현하였습니다. - 카테고리 트리 API 클라이언트 추가: CRUD 작업을 위한 API 클라이언트를 구현하였습니다. - 카테고리 값 관리 컴포넌트 및 설정 패널 추가: 사용자 인터페이스에서 카테고리 값을 관리할 수 있도록 트리 구조 기반의 컴포넌트를 추가하였습니다. - 관련 라우트 및 레지스트리 업데이트: 카테고리 트리 관련 라우트를 추가하고, 컴포넌트 레지스트리에 등록하였습니다. 이로 인해 카테고리 관리의 효율성이 향상되었습니다.
This commit is contained in:
parent
e46d216aae
commit
ae4e21e1ac
|
|
@ -83,6 +83,7 @@ import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조
|
|||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -262,6 +263,7 @@ app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연
|
|||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* 카테고리 트리 컨트롤러 (테스트용)
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 인증된 사용자 타입
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: {
|
||||
userId: string;
|
||||
companyCode: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 트리 조회
|
||||
* GET /api/category-tree/test/:tableName/:columnName
|
||||
*/
|
||||
router.get("/test/:tableName/:columnName", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const tree = await categoryTreeService.getCategoryTree(companyCode, tableName, columnName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: tree,
|
||||
});
|
||||
} 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/flat
|
||||
*/
|
||||
router.get("/test/:tableName/:columnName/flat", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const list = await categoryTreeService.getCategoryList(companyCode, tableName, columnName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: list,
|
||||
});
|
||||
} 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/value/:valueId
|
||||
*/
|
||||
router.get("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { valueId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const value = await categoryTreeService.getCategoryValue(companyCode, Number(valueId));
|
||||
|
||||
if (!value) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "카테고리 값을 찾을 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 조회 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 값 생성
|
||||
* POST /api/category-tree/test/value
|
||||
*/
|
||||
router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const input: CreateCategoryValueInput = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const createdBy = req.user?.userId;
|
||||
|
||||
if (!input.tableName || !input.columnName || !input.valueCode || !input.valueLabel) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "tableName, columnName, valueCode, valueLabel은 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
const value = await categoryTreeService.createCategoryValue(companyCode, input, createdBy);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 생성 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 값 수정
|
||||
* PUT /api/category-tree/test/value/:valueId
|
||||
*/
|
||||
router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { valueId } = req.params;
|
||||
const input: UpdateCategoryValueInput = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const updatedBy = req.user?.userId;
|
||||
|
||||
const value = await categoryTreeService.updateCategoryValue(companyCode, Number(valueId), input, updatedBy);
|
||||
|
||||
if (!value) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "카테고리 값을 찾을 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: value,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 수정 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제
|
||||
* DELETE /api/category-tree/test/value/:valueId
|
||||
*/
|
||||
router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { valueId } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const success = await categoryTreeService.deleteCategoryValue(companyCode, Number(valueId));
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "카테고리 값을 찾을 수 없습니다",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "삭제되었습니다",
|
||||
});
|
||||
} 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/columns/:tableName
|
||||
*/
|
||||
router.get("/test/columns/:tableName", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
const columns = await categoryTreeService.getCategoryColumns(companyCode, tableName);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: columns,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 컬럼 목록 조회 API 오류", { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -59,3 +59,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -55,3 +55,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,3 +71,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -59,3 +59,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* 카테고리 트리 라우트 (테스트용)
|
||||
*/
|
||||
|
||||
import categoryTreeController from "../controllers/categoryTreeController";
|
||||
|
||||
export default categoryTreeController;
|
||||
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
/**
|
||||
* 카테고리 트리 서비스 (테스트용)
|
||||
* - 트리 구조 지원 (최대 3단계: 대분류/중분류/소분류)
|
||||
*/
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 카테고리 값 타입
|
||||
export interface CategoryValue {
|
||||
valueId: number;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
valueOrder: number;
|
||||
parentValueId: number | null;
|
||||
depth: number;
|
||||
path: string | null;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
companyCode: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
createdBy: string | null;
|
||||
updatedBy: string | null;
|
||||
children?: CategoryValue[];
|
||||
}
|
||||
|
||||
// 카테고리 값 생성 입력
|
||||
export interface CreateCategoryValueInput {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
valueOrder?: number;
|
||||
parentValueId?: number | null;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// 카테고리 값 수정 입력
|
||||
export interface UpdateCategoryValueInput {
|
||||
valueCode?: string;
|
||||
valueLabel?: string;
|
||||
valueOrder?: number;
|
||||
parentValueId?: number | null;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
class CategoryTreeService {
|
||||
/**
|
||||
* 카테고리 값 목록 조회 (트리 구조로 반환)
|
||||
*/
|
||||
async getCategoryTree(companyCode: string, tableName: string, columnName: string): Promise<CategoryValue[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("카테고리 트리 조회 시작", { companyCode, tableName, columnName });
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND table_name = $2
|
||||
AND column_name = $3
|
||||
ORDER BY depth ASC, value_order ASC, value_label ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, tableName, columnName]);
|
||||
const flatList = result.rows as CategoryValue[];
|
||||
|
||||
const tree = this.buildTree(flatList);
|
||||
|
||||
logger.info("카테고리 트리 조회 완료", {
|
||||
tableName,
|
||||
columnName,
|
||||
totalCount: flatList.length,
|
||||
rootCount: tree.length,
|
||||
});
|
||||
|
||||
return tree;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 트리 조회 실패", { error: err.message, tableName, columnName });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 목록 조회 (플랫 리스트)
|
||||
*/
|
||||
async getCategoryList(companyCode: string, tableName: string, columnName: string): Promise<CategoryValue[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*')
|
||||
AND table_name = $2
|
||||
AND column_name = $3
|
||||
ORDER BY depth ASC, value_order ASC, value_label ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, tableName, columnName]);
|
||||
return result.rows as CategoryValue[];
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 목록 조회 실패", { error: err.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 단일 조회
|
||||
*/
|
||||
async getCategoryValue(companyCode: string, valueId: number): Promise<CategoryValue | null> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, valueId]);
|
||||
return result.rows[0] || null;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 조회 실패", { error: err.message, valueId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 생성
|
||||
*/
|
||||
async createCategoryValue(companyCode: string, input: CreateCategoryValueInput, createdBy?: string): Promise<CategoryValue> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
// depth 계산
|
||||
let depth = 1;
|
||||
let path = input.valueLabel;
|
||||
|
||||
if (input.parentValueId) {
|
||||
const parent = await this.getCategoryValue(companyCode, input.parentValueId);
|
||||
if (parent) {
|
||||
depth = parent.depth + 1;
|
||||
path = parent.path ? `${parent.path}/${input.valueLabel}` : input.valueLabel;
|
||||
|
||||
if (depth > 3) {
|
||||
throw new Error("카테고리는 최대 3단계까지만 가능합니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO category_values_test (
|
||||
table_name, column_name, value_code, value_label, value_order,
|
||||
parent_value_id, depth, path, description, color, icon,
|
||||
is_active, is_default, company_code, created_by, updated_by
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15
|
||||
)
|
||||
RETURNING
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
`;
|
||||
|
||||
const params = [
|
||||
input.tableName,
|
||||
input.columnName,
|
||||
input.valueCode,
|
||||
input.valueLabel,
|
||||
input.valueOrder ?? 0,
|
||||
input.parentValueId ?? null,
|
||||
depth,
|
||||
path,
|
||||
input.description ?? null,
|
||||
input.color ?? null,
|
||||
input.icon ?? null,
|
||||
input.isActive ?? true,
|
||||
input.isDefault ?? false,
|
||||
companyCode,
|
||||
createdBy ?? null,
|
||||
];
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("카테고리 값 생성 완료", {
|
||||
valueId: result.rows[0].valueId,
|
||||
valueLabel: input.valueLabel,
|
||||
depth,
|
||||
});
|
||||
|
||||
return result.rows[0];
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 생성 실패", { error: err.message, input });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 수정
|
||||
*/
|
||||
async updateCategoryValue(
|
||||
companyCode: string,
|
||||
valueId: number,
|
||||
input: UpdateCategoryValueInput,
|
||||
updatedBy?: string
|
||||
): Promise<CategoryValue | null> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const current = await this.getCategoryValue(companyCode, valueId);
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let newPath = current.path;
|
||||
let newDepth = current.depth;
|
||||
|
||||
if (input.valueLabel && input.valueLabel !== current.valueLabel) {
|
||||
if (current.parentValueId) {
|
||||
const parent = await this.getCategoryValue(companyCode, current.parentValueId);
|
||||
if (parent && parent.path) {
|
||||
newPath = `${parent.path}/${input.valueLabel}`;
|
||||
} else {
|
||||
newPath = input.valueLabel;
|
||||
}
|
||||
} else {
|
||||
newPath = input.valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.parentValueId !== undefined && input.parentValueId !== current.parentValueId) {
|
||||
if (input.parentValueId) {
|
||||
const newParent = await this.getCategoryValue(companyCode, input.parentValueId);
|
||||
if (newParent) {
|
||||
newDepth = newParent.depth + 1;
|
||||
const label = input.valueLabel ?? current.valueLabel;
|
||||
newPath = newParent.path ? `${newParent.path}/${label}` : label;
|
||||
|
||||
if (newDepth > 3) {
|
||||
throw new Error("카테고리는 최대 3단계까지만 가능합니다");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newDepth = 1;
|
||||
newPath = input.valueLabel ?? current.valueLabel;
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE category_values_test
|
||||
SET
|
||||
value_code = COALESCE($3, value_code),
|
||||
value_label = COALESCE($4, value_label),
|
||||
value_order = COALESCE($5, value_order),
|
||||
parent_value_id = $6,
|
||||
depth = $7,
|
||||
path = $8,
|
||||
description = COALESCE($9, description),
|
||||
color = COALESCE($10, color),
|
||||
icon = COALESCE($11, icon),
|
||||
is_active = COALESCE($12, is_active),
|
||||
is_default = COALESCE($13, is_default),
|
||||
updated_at = NOW(),
|
||||
updated_by = $14
|
||||
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
||||
RETURNING
|
||||
value_id AS "valueId",
|
||||
table_name AS "tableName",
|
||||
column_name AS "columnName",
|
||||
value_code AS "valueCode",
|
||||
value_label AS "valueLabel",
|
||||
value_order AS "valueOrder",
|
||||
parent_value_id AS "parentValueId",
|
||||
depth,
|
||||
path,
|
||||
description,
|
||||
color,
|
||||
icon,
|
||||
is_active AS "isActive",
|
||||
is_default AS "isDefault",
|
||||
company_code AS "companyCode",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
`;
|
||||
|
||||
const params = [
|
||||
companyCode,
|
||||
valueId,
|
||||
input.valueCode ?? null,
|
||||
input.valueLabel ?? null,
|
||||
input.valueOrder ?? null,
|
||||
input.parentValueId !== undefined ? input.parentValueId : current.parentValueId,
|
||||
newDepth,
|
||||
newPath,
|
||||
input.description ?? null,
|
||||
input.color ?? null,
|
||||
input.icon ?? null,
|
||||
input.isActive ?? null,
|
||||
input.isDefault ?? null,
|
||||
updatedBy ?? null,
|
||||
];
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
if (input.valueLabel || input.parentValueId !== undefined) {
|
||||
await this.updateChildrenPaths(companyCode, valueId, newPath || "");
|
||||
}
|
||||
|
||||
logger.info("카테고리 값 수정 완료", { valueId });
|
||||
|
||||
return result.rows[0] || null;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 수정 실패", { error: err.message, valueId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (하위 항목도 함께 삭제)
|
||||
*/
|
||||
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
DELETE FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
|
||||
RETURNING value_id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, valueId]);
|
||||
|
||||
if (result.rowCount && result.rowCount > 0) {
|
||||
logger.info("카테고리 값 삭제 완료", { valueId });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 하위 항목들의 path 업데이트
|
||||
*/
|
||||
private async updateChildrenPaths(companyCode: string, parentValueId: number, parentPath: string): Promise<void> {
|
||||
const pool = getPool();
|
||||
|
||||
const query = `
|
||||
SELECT value_id, value_label
|
||||
FROM category_values_test
|
||||
WHERE (company_code = $1 OR company_code = '*') AND parent_value_id = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [companyCode, parentValueId]);
|
||||
|
||||
for (const child of result.rows) {
|
||||
const newPath = `${parentPath}/${child.value_label}`;
|
||||
|
||||
await pool.query(`UPDATE category_values_test SET path = $1, updated_at = NOW() WHERE value_id = $2`, [
|
||||
newPath,
|
||||
child.value_id,
|
||||
]);
|
||||
|
||||
await this.updateChildrenPaths(companyCode, child.value_id, newPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 플랫 리스트를 트리 구조로 변환
|
||||
*/
|
||||
private buildTree(flatList: CategoryValue[]): CategoryValue[] {
|
||||
const map = new Map<number, CategoryValue>();
|
||||
const roots: CategoryValue[] = [];
|
||||
|
||||
for (const item of flatList) {
|
||||
map.set(item.valueId, { ...item, children: [] });
|
||||
}
|
||||
|
||||
for (const item of flatList) {
|
||||
const node = map.get(item.valueId)!;
|
||||
|
||||
if (item.parentValueId && map.has(item.parentValueId)) {
|
||||
const parent = map.get(item.parentValueId)!;
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블의 카테고리 컬럼 목록 조회
|
||||
*/
|
||||
async getCategoryColumns(companyCode: string, tableName: string): Promise<{ columnName: string; columnLabel: string }[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
const query = `
|
||||
SELECT DISTINCT column_name AS "columnName", column_label AS "columnLabel"
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type = 'category'
|
||||
AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY column_name
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [tableName, companyCode]);
|
||||
return result.rows;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 컬럼 목록 조회 실패", { error: err.message, tableName });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const categoryTreeService = new CategoryTreeService();
|
||||
|
||||
|
|
@ -591,3 +591,4 @@ const result = await executeNodeFlow(flowId, {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -597,3 +597,4 @@ POST /multilang/keys/123/override
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -364,3 +364,4 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -350,3 +350,4 @@ const getComponentValue = (componentId: string) => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -209,3 +209,4 @@ console.log("[AggregationWidget] selectableComponents:", filtered);
|
|||
- `frontend/components/screen/ScreenDesigner.tsx` - `layout.components` 전달
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,11 +32,96 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
useEffect(() => {
|
||||
if (menuObjid) {
|
||||
loadCategoryColumnsByMenu();
|
||||
} else if (tableName) {
|
||||
// menuObjid가 없으면 tableName 기반으로 조회
|
||||
loadCategoryColumnsByTable();
|
||||
} else {
|
||||
console.warn("⚠️ menuObjid가 없어서 카테고리 컬럼을 로드할 수 없습니다");
|
||||
console.warn("⚠️ menuObjid와 tableName 모두 없어서 카테고리 컬럼을 로드할 수 없습니다");
|
||||
setColumns([]);
|
||||
}
|
||||
}, [menuObjid]);
|
||||
}, [menuObjid, tableName]);
|
||||
|
||||
// tableName 기반으로 카테고리 컬럼 조회
|
||||
const loadCategoryColumnsByTable = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
console.log("🔍 테이블 기반 카테고리 컬럼 조회 시작", { tableName });
|
||||
|
||||
// table_type_columns에서 input_type='category'인 컬럼 조회
|
||||
const response = await apiClient.get(`/screen-management/tables/${tableName}/columns`);
|
||||
|
||||
console.log("✅ 테이블 컬럼 API 응답:", response.data);
|
||||
|
||||
let allColumns: any[] = [];
|
||||
if (response.data.success && response.data.data) {
|
||||
allColumns = response.data.data;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
allColumns = response.data;
|
||||
}
|
||||
|
||||
// category 타입 컬럼만 필터링
|
||||
const categoryColumns = allColumns.filter(
|
||||
(col: any) => col.inputType === "category" || col.input_type === "category"
|
||||
);
|
||||
|
||||
console.log("✅ 카테고리 컬럼 필터링 완료:", {
|
||||
total: allColumns.length,
|
||||
categoryCount: categoryColumns.length,
|
||||
});
|
||||
|
||||
// 값 개수 조회 (테스트 테이블 사용)
|
||||
const columnsWithCount = await Promise.all(
|
||||
categoryColumns.map(async (col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
const colLabel = col.columnLabel || col.column_label || colName;
|
||||
|
||||
let valueCount = 0;
|
||||
try {
|
||||
// 테스트 테이블에서 조회
|
||||
const treeResponse = await apiClient.get(`/category-tree/test/${tableName}/${colName}`);
|
||||
if (treeResponse.data.success && treeResponse.data.data) {
|
||||
valueCount = countTreeNodes(treeResponse.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`항목 개수 조회 실패 (${tableName}.${colName}):`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
tableName,
|
||||
tableLabel: tableName,
|
||||
columnName: colName,
|
||||
columnLabel: colLabel,
|
||||
inputType: "category",
|
||||
valueCount,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
setColumns(columnsWithCount);
|
||||
|
||||
// 첫 번째 컬럼 자동 선택
|
||||
if (columnsWithCount.length > 0 && !selectedColumn) {
|
||||
const firstCol = columnsWithCount[0];
|
||||
onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error);
|
||||
setColumns([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 트리 노드 수 계산 함수
|
||||
const countTreeNodes = (nodes: any[]): number => {
|
||||
let count = nodes.length;
|
||||
for (const node of nodes) {
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
count += countTreeNodes(node.children);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const loadCategoryColumnsByMenu = async () => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -99,6 +184,13 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
}),
|
||||
);
|
||||
|
||||
// 결과가 0개면 tableName 기반으로 fallback
|
||||
if (columnsWithCount.length === 0 && tableName) {
|
||||
console.log("⚠️ menuObjid 기반 조회 결과 없음, tableName 기반으로 fallback:", tableName);
|
||||
await loadCategoryColumnsByTable();
|
||||
return;
|
||||
}
|
||||
|
||||
setColumns(columnsWithCount);
|
||||
|
||||
// 첫 번째 컬럼 자동 선택
|
||||
|
|
@ -108,10 +200,16 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 카테고리 컬럼 조회 실패:", error);
|
||||
setColumns([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// 에러 시에도 tableName 기반으로 fallback
|
||||
if (tableName) {
|
||||
console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName);
|
||||
await loadCategoryColumnsByTable();
|
||||
return;
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,720 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 카테고리 값 관리 - 트리 구조 버전
|
||||
* - 3단계 트리 구조 지원 (대분류/중분류/소분류)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Tag,
|
||||
Search,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CategoryValue,
|
||||
getCategoryTree,
|
||||
createCategoryValue,
|
||||
updateCategoryValue,
|
||||
deleteCategoryValue,
|
||||
CreateCategoryValueInput,
|
||||
} from "@/lib/api/categoryTree";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface CategoryValueManagerTreeProps {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
onValueCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
// 트리 노드 컴포넌트
|
||||
interface TreeNodeProps {
|
||||
node: CategoryValue;
|
||||
level: number;
|
||||
expandedNodes: Set<number>;
|
||||
selectedValueId?: number;
|
||||
searchQuery: string;
|
||||
onToggle: (valueId: number) => void;
|
||||
onSelect: (value: CategoryValue) => void;
|
||||
onAdd: (parentValue: CategoryValue | null) => void;
|
||||
onEdit: (value: CategoryValue) => void;
|
||||
onDelete: (value: CategoryValue) => void;
|
||||
}
|
||||
|
||||
// 검색어가 노드 또는 하위에 매칭되는지 확인
|
||||
const nodeMatchesSearch = (node: CategoryValue, query: string): boolean => {
|
||||
if (!query) return true;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
if (node.valueLabel.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (node.valueCode.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (node.children) {
|
||||
return node.children.some((child) => nodeMatchesSearch(child, query));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
level,
|
||||
expandedNodes,
|
||||
selectedValueId,
|
||||
searchQuery,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpanded = expandedNodes.has(node.valueId);
|
||||
const isSelected = selectedValueId === node.valueId;
|
||||
const canAddChild = node.depth < 3;
|
||||
|
||||
// 검색 필터링
|
||||
if (searchQuery && !nodeMatchesSearch(node, searchQuery)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 깊이별 아이콘
|
||||
const getIcon = () => {
|
||||
if (hasChildren) {
|
||||
return isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-amber-500" />
|
||||
);
|
||||
}
|
||||
return <Tag className="h-4 w-4 text-blue-500" />;
|
||||
};
|
||||
|
||||
// 깊이별 라벨
|
||||
const getDepthLabel = () => {
|
||||
switch (node.depth) {
|
||||
case 1:
|
||||
return "대분류";
|
||||
case 2:
|
||||
return "중분류";
|
||||
case 3:
|
||||
return "소분류";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md px-2 py-2 transition-colors",
|
||||
isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
||||
onClick={() => onSelect(node)}
|
||||
>
|
||||
{/* 확장 토글 */}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-muted flex h-6 w-6 items-center justify-center rounded"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (hasChildren) {
|
||||
onToggle(node.valueId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasChildren ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<span className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 아이콘 */}
|
||||
{getIcon()}
|
||||
|
||||
{/* 라벨 */}
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<span className={cn("text-sm", node.depth === 1 && "font-medium")}>{node.valueLabel}</span>
|
||||
<span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">{getDepthLabel()}</span>
|
||||
</div>
|
||||
|
||||
{/* 비활성 표시 */}
|
||||
{!node.isActive && (
|
||||
<span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-[10px]">비활성</span>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{canAddChild && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAdd(node);
|
||||
}}
|
||||
title="하위 추가"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(node);
|
||||
}}
|
||||
title="수정"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node);
|
||||
}}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자식 노드 */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNode
|
||||
key={child.valueId}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
expandedNodes={expandedNodes}
|
||||
selectedValueId={selectedValueId}
|
||||
searchQuery={searchQuery}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onAdd={onAdd}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> = ({
|
||||
tableName,
|
||||
columnName,
|
||||
columnLabel,
|
||||
onValueCountChange,
|
||||
}) => {
|
||||
// 상태
|
||||
const [tree, setTree] = useState<CategoryValue[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
|
||||
const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
|
||||
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
|
||||
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// 전체 값 개수 계산
|
||||
const countAllValues = useCallback((nodes: CategoryValue[]): number => {
|
||||
let count = nodes.length;
|
||||
for (const node of nodes) {
|
||||
if (node.children) {
|
||||
count += countAllValues(node.children);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, []);
|
||||
|
||||
// 활성 노드만 필터링
|
||||
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
|
||||
return nodes
|
||||
.filter((node) => node.isActive !== false)
|
||||
.map((node) => ({
|
||||
...node,
|
||||
children: node.children ? filterActiveNodes(node.children) : undefined,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 데이터 로드
|
||||
const loadTree = useCallback(async () => {
|
||||
if (!tableName || !columnName) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getCategoryTree(tableName, columnName);
|
||||
if (response.success && response.data) {
|
||||
let filteredTree = response.data;
|
||||
|
||||
// 비활성 필터링
|
||||
if (!showInactive) {
|
||||
filteredTree = filterActiveNodes(response.data);
|
||||
}
|
||||
|
||||
setTree(filteredTree);
|
||||
|
||||
// 1단계 노드는 기본 펼침
|
||||
const rootIds = new Set(filteredTree.map((n) => n.valueId));
|
||||
setExpandedNodes(rootIds);
|
||||
|
||||
// 전체 개수 업데이트
|
||||
const totalCount = countAllValues(response.data);
|
||||
onValueCountChange?.(totalCount);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 트리 로드 오류:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
}, [loadTree]);
|
||||
|
||||
// 모든 노드 펼치기
|
||||
const expandAll = () => {
|
||||
const allIds = new Set<number>();
|
||||
const collectIds = (nodes: CategoryValue[]) => {
|
||||
for (const node of nodes) {
|
||||
allIds.add(node.valueId);
|
||||
if (node.children) {
|
||||
collectIds(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collectIds(tree);
|
||||
setExpandedNodes(allIds);
|
||||
};
|
||||
|
||||
// 모든 노드 접기
|
||||
const collapseAll = () => {
|
||||
setExpandedNodes(new Set());
|
||||
};
|
||||
|
||||
// 토글 핸들러
|
||||
const handleToggle = (valueId: number) => {
|
||||
setExpandedNodes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(valueId)) {
|
||||
next.delete(valueId);
|
||||
} else {
|
||||
next.add(valueId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 추가 모달 열기
|
||||
const handleOpenAddModal = (parent: CategoryValue | null) => {
|
||||
setParentValue(parent);
|
||||
setFormData({
|
||||
valueCode: "",
|
||||
valueLabel: "",
|
||||
description: "",
|
||||
color: "",
|
||||
isActive: true,
|
||||
});
|
||||
setIsAddModalOpen(true);
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const handleOpenEditModal = (value: CategoryValue) => {
|
||||
setEditingValue(value);
|
||||
setFormData({
|
||||
valueCode: value.valueCode,
|
||||
valueLabel: value.valueLabel,
|
||||
description: value.description || "",
|
||||
color: value.color || "",
|
||||
isActive: value.isActive,
|
||||
});
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 다이얼로그 열기
|
||||
const handleOpenDeleteDialog = (value: CategoryValue) => {
|
||||
setDeletingValue(value);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 코드 자동 생성 함수
|
||||
const generateCode = () => {
|
||||
const timestamp = Date.now().toString(36).toUpperCase();
|
||||
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
|
||||
return `CAT_${timestamp}_${random}`;
|
||||
};
|
||||
|
||||
// 추가 처리
|
||||
const handleAdd = async () => {
|
||||
if (!formData.valueLabel) {
|
||||
toast.error("이름은 필수입니다");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 코드 자동 생성
|
||||
const autoCode = generateCode();
|
||||
|
||||
const input: CreateCategoryValueInput = {
|
||||
tableName,
|
||||
columnName,
|
||||
valueCode: autoCode,
|
||||
valueLabel: formData.valueLabel,
|
||||
parentValueId: parentValue?.valueId || null,
|
||||
description: formData.description || undefined,
|
||||
color: formData.color || undefined,
|
||||
isActive: formData.isActive,
|
||||
};
|
||||
|
||||
const response = await createCategoryValue(input);
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 추가되었습니다");
|
||||
setIsAddModalOpen(false);
|
||||
loadTree();
|
||||
if (parentValue) {
|
||||
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
|
||||
}
|
||||
} else {
|
||||
toast.error(response.error || "추가 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 추가 오류:", error);
|
||||
toast.error("카테고리 추가 중 오류가 발생했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
// 수정 처리
|
||||
const handleEdit = async () => {
|
||||
if (!editingValue) return;
|
||||
|
||||
try {
|
||||
// 코드는 변경하지 않음 (기존 코드 유지)
|
||||
const response = await updateCategoryValue(editingValue.valueId, {
|
||||
valueLabel: formData.valueLabel,
|
||||
description: formData.description || undefined,
|
||||
color: formData.color || undefined,
|
||||
isActive: formData.isActive,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 수정되었습니다");
|
||||
setIsEditModalOpen(false);
|
||||
loadTree();
|
||||
} else {
|
||||
toast.error(response.error || "수정 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 수정 오류:", error);
|
||||
toast.error("카테고리 수정 중 오류가 발생했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 처리
|
||||
const handleDelete = async () => {
|
||||
if (!deletingValue) return;
|
||||
|
||||
try {
|
||||
const response = await deleteCategoryValue(deletingValue.valueId);
|
||||
if (response.success) {
|
||||
toast.success("카테고리가 삭제되었습니다");
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedValue(null);
|
||||
loadTree();
|
||||
} else {
|
||||
toast.error(response.error || "삭제 실패");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 삭제 오류:", error);
|
||||
toast.error("카테고리 삭제 중 오류가 발생했습니다");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between border-b pb-3">
|
||||
<h3 className="text-base font-semibold">{columnLabel} 카테고리</h3>
|
||||
<Button variant="default" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleOpenAddModal(null)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
대분류 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 툴바 */}
|
||||
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
{/* 검색 */}
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch id="showInactive" checked={showInactive} onCheckedChange={setShowInactive} />
|
||||
<Label htmlFor="showInactive" className="cursor-pointer text-xs">
|
||||
비활성 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={expandAll}>
|
||||
전체 펼침
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={collapseAll}>
|
||||
전체 접기
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={loadTree} title="새로고침">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 트리 */}
|
||||
<div className="bg-card min-h-[300px] flex-1 overflow-y-auto rounded-md border">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">로딩 중...</div>
|
||||
</div>
|
||||
) : tree.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Folder className="text-muted-foreground/30 mb-3 h-12 w-12" />
|
||||
<p className="text-muted-foreground text-sm">카테고리가 없습니다</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">상단의 대분류 추가 버튼을 클릭하여 시작하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{tree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.valueId}
|
||||
node={node}
|
||||
level={0}
|
||||
expandedNodes={expandedNodes}
|
||||
selectedValueId={selectedValue?.valueId}
|
||||
searchQuery={searchQuery}
|
||||
onToggle={handleToggle}
|
||||
onSelect={setSelectedValue}
|
||||
onAdd={handleOpenAddModal}
|
||||
onEdit={handleOpenEditModal}
|
||||
onDelete={handleOpenDeleteDialog}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* 추가 모달 */}
|
||||
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{parentValue ? `"${parentValue.valueLabel}" 하위 추가` : "대분류 추가"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{parentValue ? `${parentValue.depth + 1}단계 카테고리를 추가합니다` : "1단계 대분류 카테고리를 추가합니다"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="valueLabel" className="text-xs sm:text-sm">
|
||||
이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="valueLabel"
|
||||
value={formData.valueLabel}
|
||||
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
|
||||
placeholder="카테고리 이름을 입력하세요"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">코드는 자동으로 생성됩니다</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="선택 사항"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor="isActive" className="cursor-pointer text-sm">
|
||||
활성 상태
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setIsAddModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
추가
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 수정 모달 */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">카테고리 수정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">카테고리 정보를 수정합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="editValueLabel" className="text-xs sm:text-sm">
|
||||
이름 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="editValueLabel"
|
||||
value={formData.valueLabel}
|
||||
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="editDescription" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
<Input
|
||||
id="editDescription"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="editIsActive"
|
||||
checked={formData.isActive}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
|
||||
/>
|
||||
<Label htmlFor="editIsActive" className="cursor-pointer text-sm">
|
||||
활성 상태
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="h-9 flex-1 text-sm sm:flex-none">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>카테고리 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<strong>{deletingValue?.valueLabel}</strong>을(를) 삭제하시겠습니까?
|
||||
{deletingValue?.children && deletingValue.children.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-destructive">
|
||||
하위 카테고리 {deletingValue.children.length}개도 모두 함께 삭제됩니다.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryValueManagerTree;
|
||||
|
||||
|
|
@ -144,3 +144,4 @@ export const useActiveTabOptional = () => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -201,3 +201,4 @@ export function applyAutoFillToFormData(
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* 카테고리 트리 API 클라이언트 (테스트용)
|
||||
* - 트리 구조 CRUD 지원
|
||||
*/
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// 카테고리 값 타입
|
||||
export interface CategoryValue {
|
||||
valueId: number;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
valueOrder: number;
|
||||
parentValueId: number | null;
|
||||
depth: number; // 1=대분류, 2=중분류, 3=소분류
|
||||
path: string | null;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
companyCode: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// 트리 구조용
|
||||
children?: CategoryValue[];
|
||||
}
|
||||
|
||||
// 카테고리 값 생성 입력
|
||||
export interface CreateCategoryValueInput {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
valueOrder?: number;
|
||||
parentValueId?: number | null;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// 카테고리 값 수정 입력
|
||||
export interface UpdateCategoryValueInput {
|
||||
valueCode?: string;
|
||||
valueLabel?: string;
|
||||
valueOrder?: number;
|
||||
parentValueId?: number | null;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 트리 조회
|
||||
*/
|
||||
export async function getCategoryTree(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<ApiResponse<CategoryValue[]>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/category-tree/test/${tableName}/${columnName}`);
|
||||
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 getCategoryList(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<ApiResponse<CategoryValue[]>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/category-tree/test/${tableName}/${columnName}/flat`);
|
||||
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 getCategoryValue(valueId: number): Promise<ApiResponse<CategoryValue>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/category-tree/test/value/${valueId}`);
|
||||
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 createCategoryValue(
|
||||
input: CreateCategoryValueInput
|
||||
): Promise<ApiResponse<CategoryValue>> {
|
||||
try {
|
||||
const response = await apiClient.post("/category-tree/test/value", input);
|
||||
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 updateCategoryValue(
|
||||
valueId: number,
|
||||
input: UpdateCategoryValueInput
|
||||
): Promise<ApiResponse<CategoryValue>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/category-tree/test/value/${valueId}`, input);
|
||||
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 deleteCategoryValue(valueId: number): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/category-tree/test/value/${valueId}`);
|
||||
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 getCategoryColumns(
|
||||
tableName: string
|
||||
): Promise<ApiResponse<{ columnName: string; columnLabel: string }[]>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/category-tree/test/columns/${tableName}`);
|
||||
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,6 +105,7 @@ import "./v2-rack-structure/RackStructureRenderer";
|
|||
import "./v2-location-swap-selector/LocationSwapSelectorRenderer";
|
||||
import "./v2-table-search-widget";
|
||||
import "./v2-tabs-widget/tabs-component";
|
||||
import "./v2-category-manager/V2CategoryManagerRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* V2 카테고리 관리 컴포넌트
|
||||
* - 트리 구조 기반 카테고리 값 관리
|
||||
* - 3단계 계층 구조 지원 (대분류/중분류/소분류)
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
import { GripVertical, LayoutList, TreeDeciduous } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types";
|
||||
|
||||
interface V2CategoryManagerComponentProps {
|
||||
tableName?: string;
|
||||
menuObjid?: number;
|
||||
selectedScreen?: { tableName?: string; menuObjid?: number; menuId?: number };
|
||||
config?: Partial<V2CategoryManagerConfig>;
|
||||
componentConfig?: Partial<V2CategoryManagerConfig>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function V2CategoryManagerComponent({
|
||||
tableName,
|
||||
menuObjid,
|
||||
selectedScreen,
|
||||
config: externalConfig,
|
||||
componentConfig,
|
||||
...props
|
||||
}: V2CategoryManagerComponentProps) {
|
||||
// 설정 병합 (componentConfig도 포함)
|
||||
const config: V2CategoryManagerConfig = {
|
||||
...defaultV2CategoryManagerConfig,
|
||||
...externalConfig,
|
||||
...componentConfig,
|
||||
};
|
||||
|
||||
// tableName 우선순위: props > selectedScreen > componentConfig
|
||||
const effectiveTableName = tableName || selectedScreen?.tableName || (componentConfig as any)?.tableName || "";
|
||||
|
||||
// menuObjid 우선순위: props > selectedScreen
|
||||
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
|
||||
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
|
||||
|
||||
// 디버그 로그
|
||||
useEffect(() => {
|
||||
console.log("🔍 V2CategoryManagerComponent props:", {
|
||||
tableName,
|
||||
menuObjid,
|
||||
selectedScreen,
|
||||
effectiveTableName,
|
||||
effectiveMenuObjid,
|
||||
config,
|
||||
});
|
||||
}, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]);
|
||||
|
||||
// 선택된 컬럼 상태
|
||||
const [selectedColumn, setSelectedColumn] = useState<{
|
||||
uniqueKey: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
tableName: string;
|
||||
} | null>(null);
|
||||
|
||||
// 뷰 모드 상태
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode);
|
||||
|
||||
// 좌측 패널 너비 상태
|
||||
const [leftWidth, setLeftWidth] = useState(config.leftPanelWidth);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
|
||||
// 리사이저 핸들러
|
||||
const handleMouseDown = useCallback(() => {
|
||||
isDraggingRef.current = true;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (!isDraggingRef.current || !containerRef.current) return;
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
||||
|
||||
if (newLeftWidth >= 10 && newLeftWidth <= 40) {
|
||||
setLeftWidth(newLeftWidth);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isDraggingRef.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
// 컬럼 선택 핸들러
|
||||
const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => {
|
||||
const columnName = uniqueKey.split(".")[1];
|
||||
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full min-h-[10px] gap-0" style={{ height: config.height }}>
|
||||
{/* 좌측: 카테고리 컬럼 리스트 */}
|
||||
{config.showColumnList && (
|
||||
<>
|
||||
<div style={{ width: `${leftWidth}%` }} className="pr-3">
|
||||
<CategoryColumnList
|
||||
tableName={effectiveTableName}
|
||||
selectedColumn={selectedColumn?.uniqueKey || null}
|
||||
onColumnSelect={handleColumnSelect}
|
||||
menuObjid={effectiveMenuObjid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="group hover:bg-accent/50 relative flex w-3 cursor-col-resize items-center justify-center border-r transition-colors"
|
||||
>
|
||||
<GripVertical className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-colors" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 우측: 카테고리 값 관리 */}
|
||||
<div style={{ width: config.showColumnList ? `${100 - leftWidth - 1}%` : "100%" }} className="flex flex-col pl-3">
|
||||
{/* 뷰 모드 토글 */}
|
||||
{config.showViewModeToggle && (
|
||||
<div className="mb-2 flex items-center justify-end gap-1">
|
||||
<span className="text-muted-foreground mr-2 text-xs">보기 방식:</span>
|
||||
<div className="flex rounded-md border p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "tree" && "bg-accent")}
|
||||
onClick={() => setViewMode("tree")}
|
||||
>
|
||||
<TreeDeciduous className="h-3.5 w-3.5" />
|
||||
트리
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-7 gap-1.5 px-2.5 text-xs", viewMode === "list" && "bg-accent")}
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<LayoutList className="h-3.5 w-3.5" />
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 값 관리 컴포넌트 */}
|
||||
<div className="min-h-0 flex-1">
|
||||
{selectedColumn ? (
|
||||
viewMode === "tree" ? (
|
||||
<CategoryValueManagerTree
|
||||
key={`tree-${selectedColumn.uniqueKey}`}
|
||||
tableName={selectedColumn.tableName}
|
||||
columnName={selectedColumn.columnName}
|
||||
columnLabel={selectedColumn.columnLabel}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
key={`list-${selectedColumn.uniqueKey}`}
|
||||
tableName={selectedColumn.tableName}
|
||||
columnName={selectedColumn.columnName}
|
||||
columnLabel={selectedColumn.columnLabel}
|
||||
menuObjid={effectiveMenuObjid}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<TreeDeciduous className="text-muted-foreground/30 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default V2CategoryManagerComponent;
|
||||
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* V2 카테고리 관리 설정 패널
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { V2CategoryManagerConfig, defaultV2CategoryManagerConfig, ViewMode } from "./types";
|
||||
|
||||
interface V2CategoryManagerConfigPanelProps {
|
||||
config: Partial<V2CategoryManagerConfig>;
|
||||
onChange: (config: Partial<V2CategoryManagerConfig>) => void;
|
||||
}
|
||||
|
||||
export function V2CategoryManagerConfigPanel({ config: externalConfig, onChange }: V2CategoryManagerConfigPanelProps) {
|
||||
const config: V2CategoryManagerConfig = {
|
||||
...defaultV2CategoryManagerConfig,
|
||||
...externalConfig,
|
||||
};
|
||||
|
||||
const handleChange = <K extends keyof V2CategoryManagerConfig>(key: K, value: V2CategoryManagerConfig[K]) => {
|
||||
onChange({ ...config, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 뷰 모드 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium">뷰 모드 설정</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">기본 뷰 모드</Label>
|
||||
<Select value={config.viewMode} onValueChange={(value: ViewMode) => handleChange("viewMode", value)}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tree">트리 뷰</SelectItem>
|
||||
<SelectItem value="list">목록 뷰</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">컴포넌트가 처음 표시될 때의 기본 뷰 모드</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">뷰 모드 토글 표시</Label>
|
||||
<p className="text-muted-foreground text-[10px]">트리/목록 전환 버튼 표시</p>
|
||||
</div>
|
||||
<Switch checked={config.showViewModeToggle} onCheckedChange={(checked) => handleChange("showViewModeToggle", checked)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 트리 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium">트리 설정</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">기본 펼침 단계</Label>
|
||||
<Select
|
||||
value={String(config.defaultExpandLevel)}
|
||||
onValueChange={(value) => handleChange("defaultExpandLevel", Number(value))}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1단계 (대분류만)</SelectItem>
|
||||
<SelectItem value="2">2단계 (중분류까지)</SelectItem>
|
||||
<SelectItem value="3">3단계 (전체 펼침)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">비활성 항목 표시</Label>
|
||||
<p className="text-muted-foreground text-[10px]">비활성화된 카테고리도 표시</p>
|
||||
</div>
|
||||
<Switch checked={config.showInactiveItems} onCheckedChange={(checked) => handleChange("showInactiveItems", checked)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-sm font-medium">레이아웃 설정</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">컬럼 목록 표시</Label>
|
||||
<p className="text-muted-foreground text-[10px]">좌측 카테고리 컬럼 목록 패널</p>
|
||||
</div>
|
||||
<Switch checked={config.showColumnList} onCheckedChange={(checked) => handleChange("showColumnList", checked)} />
|
||||
</div>
|
||||
|
||||
{config.showColumnList && (
|
||||
<div>
|
||||
<Label className="text-xs">좌측 패널 너비 (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={40}
|
||||
value={config.leftPanelWidth}
|
||||
onChange={(e) => handleChange("leftPanelWidth", Number(e.target.value))}
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">10~40% 범위에서 설정 가능</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default V2CategoryManagerConfigPanel;
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* V2 카테고리 관리 렌더러
|
||||
* - 컴포넌트 레지스트리 자동 등록
|
||||
*/
|
||||
|
||||
import { V2CategoryManagerDefinition } from "./index";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
|
||||
// 컴포넌트 레지스트리에 등록
|
||||
ComponentRegistry.registerComponent(V2CategoryManagerDefinition);
|
||||
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { V2CategoryManagerComponent } from "./V2CategoryManagerComponent";
|
||||
import { V2CategoryManagerConfigPanel } from "./V2CategoryManagerConfigPanel";
|
||||
import { defaultV2CategoryManagerConfig } from "./types";
|
||||
|
||||
/**
|
||||
* V2 카테고리 관리 컴포넌트 정의
|
||||
* - 트리 구조 기반 카테고리 값 관리
|
||||
* - 3단계 계층 구조 지원 (대분류/중분류/소분류)
|
||||
*/
|
||||
export const V2CategoryManagerDefinition = createComponentDefinition({
|
||||
id: "v2-category-manager",
|
||||
name: "카테고리 관리 (V2)",
|
||||
nameEng: "Category Manager V2",
|
||||
description: "트리 구조 기반 카테고리 값 관리 컴포넌트 (3단계 계층 지원)",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "category",
|
||||
component: V2CategoryManagerComponent,
|
||||
defaultConfig: defaultV2CategoryManagerConfig,
|
||||
defaultSize: { width: 1000, height: 600 },
|
||||
configPanel: V2CategoryManagerConfigPanel,
|
||||
icon: "FolderTree",
|
||||
tags: ["카테고리", "트리", "계층", "분류", "관리"],
|
||||
version: "2.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type { V2CategoryManagerConfig, CategoryValue, ViewMode } from "./types";
|
||||
export { V2CategoryManagerComponent } from "./V2CategoryManagerComponent";
|
||||
export { V2CategoryManagerConfigPanel } from "./V2CategoryManagerConfigPanel";
|
||||
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* V2 카테고리 관리 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
// 카테고리 값 타입
|
||||
export interface CategoryValue {
|
||||
valueId: number;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
valueCode: string;
|
||||
valueLabel: string;
|
||||
valueOrder: number;
|
||||
parentValueId: number | null;
|
||||
depth: number;
|
||||
path: string | null;
|
||||
description: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
companyCode: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
children?: CategoryValue[];
|
||||
}
|
||||
|
||||
// 뷰 모드 타입
|
||||
export type ViewMode = "tree" | "list";
|
||||
|
||||
// 컴포넌트 설정 타입
|
||||
export interface V2CategoryManagerConfig {
|
||||
tableName?: string;
|
||||
columnName?: string;
|
||||
menuObjid?: number;
|
||||
viewMode: ViewMode;
|
||||
showViewModeToggle: boolean;
|
||||
defaultExpandLevel: number;
|
||||
showInactiveItems: boolean;
|
||||
leftPanelWidth: number;
|
||||
showColumnList: boolean;
|
||||
height: string | number;
|
||||
}
|
||||
|
||||
// 기본 설정
|
||||
export const defaultV2CategoryManagerConfig: V2CategoryManagerConfig = {
|
||||
viewMode: "tree",
|
||||
showViewModeToggle: true,
|
||||
defaultExpandLevel: 1,
|
||||
showInactiveItems: false,
|
||||
leftPanelWidth: 15,
|
||||
showColumnList: true,
|
||||
height: "100%",
|
||||
};
|
||||
|
||||
|
|
@ -1693,3 +1693,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -540,3 +540,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -527,3 +527,4 @@ function ScreenViewPage() {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue