- Introduced new documents detailing the modifications made to the category tree modal for continuous registration mode. - Updated the functionality to allow the modal to close after saving or remain open based on user preference via a checkbox. - Enhanced the user experience by aligning the modal behavior with existing patterns in the project. - Included a checklist to track implementation progress and ensure thorough testing. These changes aim to improve the usability and consistency of the category management feature in the application.
949 lines
30 KiB
TypeScript
949 lines
30 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 카테고리 값 관리 - 트리 구조 버전
|
|
* - 3단계 트리 구조 지원 (대분류/중분류/소분류)
|
|
* - 체크박스를 통한 다중 선택 및 일괄 삭제 지원
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } 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 { Checkbox } from "@/components/ui/checkbox";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
CategoryValue,
|
|
getCategoryTree,
|
|
createCategoryValue,
|
|
updateCategoryValue,
|
|
deleteCategoryValue,
|
|
checkCanDeleteCategoryValue,
|
|
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;
|
|
checkedIds: Set<number>;
|
|
onToggle: (valueId: number) => void;
|
|
onSelect: (value: CategoryValue) => void;
|
|
onAdd: (parentValue: CategoryValue | null) => void;
|
|
onEdit: (value: CategoryValue) => void;
|
|
onDelete: (value: CategoryValue) => void;
|
|
onCheck: (valueId: number, checked: boolean) => 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,
|
|
checkedIds,
|
|
onToggle,
|
|
onSelect,
|
|
onAdd,
|
|
onEdit,
|
|
onDelete,
|
|
onCheck,
|
|
}) => {
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
const isExpanded = expandedNodes.has(node.valueId);
|
|
const isSelected = selectedValueId === node.valueId;
|
|
const isChecked = checkedIds.has(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-primary" />;
|
|
};
|
|
|
|
// 깊이별 라벨
|
|
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",
|
|
isChecked && "bg-primary/5",
|
|
"cursor-pointer",
|
|
)}
|
|
style={{ paddingLeft: `${level * 20 + 8}px` }}
|
|
onClick={() => onSelect(node)}
|
|
>
|
|
{/* 체크박스 */}
|
|
<Checkbox
|
|
checked={isChecked}
|
|
onCheckedChange={(checked) => {
|
|
onCheck(node.valueId, checked as boolean);
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="mr-1"
|
|
/>
|
|
|
|
{/* 확장 토글 */}
|
|
<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}
|
|
checkedIds={checkedIds}
|
|
onToggle={onToggle}
|
|
onSelect={onSelect}
|
|
onAdd={onAdd}
|
|
onEdit={onEdit}
|
|
onDelete={onDelete}
|
|
onCheck={onCheck}
|
|
/>
|
|
))}
|
|
</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 [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
|
|
|
|
// 모달 상태
|
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
|
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
|
|
const [continuousAdd, setContinuousAdd] = useState(false);
|
|
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
|
|
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
|
|
|
|
// 추가 모달 input ref
|
|
const addNameRef = useRef<HTMLInputElement>(null);
|
|
const addDescRef = useRef<HTMLInputElement>(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,
|
|
}));
|
|
}, []);
|
|
|
|
// 데이터 로드 (keepExpanded: true면 기존 펼침 상태 유지)
|
|
const loadTree = useCallback(
|
|
async (keepExpanded = false) => {
|
|
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);
|
|
|
|
// 기존 펼침 상태 유지하지 않으면 모두 접기 (대분류만 표시)
|
|
if (!keepExpanded) {
|
|
setExpandedNodes(new Set());
|
|
}
|
|
|
|
// 전체 개수 업데이트
|
|
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 handleCheck = useCallback((valueId: number, checked: boolean) => {
|
|
setCheckedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (checked) {
|
|
next.add(valueId);
|
|
} else {
|
|
next.delete(valueId);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// 전체 선택/해제
|
|
const handleSelectAll = useCallback(() => {
|
|
if (checkedIds.size === countAllValues(tree)) {
|
|
setCheckedIds(new Set());
|
|
} else {
|
|
const allIds = new Set<number>();
|
|
const collectAllIds = (nodes: CategoryValue[]) => {
|
|
for (const node of nodes) {
|
|
allIds.add(node.valueId);
|
|
if (node.children) {
|
|
collectAllIds(node.children);
|
|
}
|
|
}
|
|
};
|
|
collectAllIds(tree);
|
|
setCheckedIds(allIds);
|
|
}
|
|
}, [checkedIds.size, tree, countAllValues]);
|
|
|
|
// 선택 해제
|
|
const handleClearSelection = useCallback(() => {
|
|
setCheckedIds(new Set());
|
|
}, []);
|
|
|
|
// 추가 모달 열기
|
|
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 = async (value: CategoryValue) => {
|
|
try {
|
|
const response = await checkCanDeleteCategoryValue(value.valueId);
|
|
if (response.success && response.data) {
|
|
if (!response.data.canDelete) {
|
|
toast.error(response.data.reason || "이 카테고리는 삭제할 수 없습니다");
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// 사전 확인 실패 시에도 다이얼로그는 열어줌 (삭제 시 백엔드에서 재검증)
|
|
}
|
|
|
|
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("카테고리가 추가되었습니다");
|
|
await loadTree(true);
|
|
if (parentValue) {
|
|
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
|
|
}
|
|
|
|
if (continuousAdd) {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
valueCode: "",
|
|
valueLabel: "",
|
|
description: "",
|
|
color: "",
|
|
}));
|
|
setTimeout(() => addNameRef.current?.focus(), 50);
|
|
} else {
|
|
setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true });
|
|
setIsAddModalOpen(false);
|
|
}
|
|
} 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(true); // 기존 펼침 상태 유지
|
|
} 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);
|
|
setCheckedIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(deletingValue.valueId);
|
|
return next;
|
|
});
|
|
loadTree(true); // 기존 펼침 상태 유지
|
|
} else {
|
|
toast.error(response.error || "삭제 실패");
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 삭제 오류:", error);
|
|
toast.error("카테고리 삭제 중 오류가 발생했습니다");
|
|
}
|
|
};
|
|
|
|
// 다중 삭제 처리
|
|
const handleBulkDelete = async () => {
|
|
if (checkedIds.size === 0) return;
|
|
|
|
try {
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
const failMessages: string[] = [];
|
|
|
|
for (const valueId of Array.from(checkedIds)) {
|
|
try {
|
|
const response = await deleteCategoryValue(valueId);
|
|
if (response.success) {
|
|
successCount++;
|
|
} else {
|
|
failCount++;
|
|
if (response.error) failMessages.push(response.error);
|
|
}
|
|
} catch {
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
setIsBulkDeleteDialogOpen(false);
|
|
setCheckedIds(new Set());
|
|
setSelectedValue(null);
|
|
loadTree(true);
|
|
|
|
if (failCount === 0) {
|
|
toast.success(`${successCount}개 카테고리가 삭제되었습니다`);
|
|
} else if (successCount === 0) {
|
|
toast.error(`삭제할 수 없습니다: ${failMessages[0] || "삭제 실패"}`);
|
|
} else {
|
|
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패 (사용 중이거나 하위 항목 존재)`);
|
|
}
|
|
} 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">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-base font-semibold">{columnLabel} 카테고리</h3>
|
|
{checkedIds.size > 0 && (
|
|
<span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
|
|
{checkedIds.size}개 선택
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{checkedIds.size > 0 && (
|
|
<>
|
|
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={handleClearSelection}>
|
|
선택 해제
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
className="h-8 gap-1.5 text-xs"
|
|
onClick={() => setIsBulkDeleteDialogOpen(true)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
선택 삭제
|
|
</Button>
|
|
</>
|
|
)}
|
|
<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>
|
|
|
|
{/* 툴바 */}
|
|
<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 top-1/2 left-2.5 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={handleSelectAll}>
|
|
{checkedIds.size === countAllValues(tree) && tree.length > 0 ? "전체 해제" : "전체 선택"}
|
|
</Button>
|
|
<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}
|
|
checkedIds={checkedIds}
|
|
onToggle={handleToggle}
|
|
onSelect={setSelectedValue}
|
|
onAdd={handleOpenAddModal}
|
|
onEdit={handleOpenEditModal}
|
|
onDelete={handleOpenDeleteDialog}
|
|
onCheck={handleCheck}
|
|
/>
|
|
))}
|
|
</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
|
|
ref={addNameRef}
|
|
id="valueLabel"
|
|
value={formData.valueLabel}
|
|
onChange={(e) => setFormData({ ...formData, valueLabel: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
addDescRef.current?.focus();
|
|
}
|
|
}}
|
|
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
|
|
ref={addDescRef}
|
|
id="description"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleAdd();
|
|
}
|
|
}}
|
|
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>
|
|
|
|
<div className="border-t px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="tree-continuous-add"
|
|
checked={continuousAdd}
|
|
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
|
|
/>
|
|
<Label htmlFor="tree-continuous-add" className="cursor-pointer text-sm font-normal select-none">
|
|
저장 후 계속 입력 (연속 등록 모드)
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
</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>을(를) 삭제하시겠습니까?
|
|
<br />
|
|
<span className="text-muted-foreground text-xs">삭제된 카테고리는 복구할 수 없습니다.</span>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDelete}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
삭제
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* 다중 삭제 확인 다이얼로그 */}
|
|
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>카테고리 일괄 삭제</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
선택한 <strong>{checkedIds.size}개</strong> 카테고리를 삭제하시겠습니까?
|
|
<br />
|
|
<span className="text-muted-foreground text-xs">삭제된 카테고리는 복구할 수 없습니다.</span>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleBulkDelete}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{checkedIds.size}개 삭제
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CategoryValueManagerTree;
|