카테고리 트리 기능 추가 및 관련 API 구현

- 카테고리 트리 컨트롤러와 서비스 추가: 트리 구조를 지원하는 카테고리 값 관리 기능을 구현하였습니다.
- 카테고리 트리 API 클라이언트 추가: CRUD 작업을 위한 API 클라이언트를 구현하였습니다.
- 카테고리 값 관리 컴포넌트 및 설정 패널 추가: 사용자 인터페이스에서 카테고리 값을 관리할 수 있도록 트리 구조 기반의 컴포넌트를 추가하였습니다.
- 관련 라우트 및 레지스트리 업데이트: 카테고리 트리 관련 라우트를 추가하고, 컴포넌트 레지스트리에 등록하였습니다.

이로 인해 카테고리 관리의 효율성이 향상되었습니다.
This commit is contained in:
kjs 2026-01-21 15:03:27 +09:00
parent e46d216aae
commit ae4e21e1ac
27 changed files with 2213 additions and 5 deletions

View File

@ -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); // 임시 주석

View File

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

View File

@ -59,3 +59,4 @@ export default router;

View File

@ -55,3 +55,4 @@ export default router;

View File

@ -71,3 +71,4 @@ export default router;

View File

@ -59,3 +59,4 @@ export default router;

View File

@ -0,0 +1,8 @@
/**
* ()
*/
import categoryTreeController from "../controllers/categoryTreeController";
export default categoryTreeController;

View File

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

View File

@ -591,3 +591,4 @@ const result = await executeNodeFlow(flowId, {

View File

@ -597,3 +597,4 @@ POST /multilang/keys/123/override

View File

@ -364,3 +364,4 @@

View File

@ -350,3 +350,4 @@ const getComponentValue = (componentId: string) => {

View File

@ -209,3 +209,4 @@ console.log("[AggregationWidget] selectableComponents:", filtered);
- `frontend/components/screen/ScreenDesigner.tsx` - `layout.components` 전달

View File

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

View File

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

View File

@ -144,3 +144,4 @@ export const useActiveTabOptional = () => {

View File

@ -201,3 +201,4 @@ export function applyAutoFillToFormData(

View File

@ -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 || "카테고리 컬럼 조회 실패",
};
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
"use client";
/**
* V2
* -
*/
import { V2CategoryManagerDefinition } from "./index";
import { ComponentRegistry } from "../../ComponentRegistry";
// 컴포넌트 레지스트리에 등록
ComponentRegistry.registerComponent(V2CategoryManagerDefinition);

View File

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

View File

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

View File

@ -1693,3 +1693,4 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@ -540,3 +540,4 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@ -527,3 +527,4 @@ function ScreenViewPage() {