카테고리 트리 기능 추가 및 관련 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

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