848 lines
33 KiB
TypeScript
848 lines
33 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useEffect, useCallback } from "react";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Textarea } from "@/components/ui/textarea";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import {
|
||
|
|
AlertDialog,
|
||
|
|
AlertDialogAction,
|
||
|
|
AlertDialogCancel,
|
||
|
|
AlertDialogContent,
|
||
|
|
AlertDialogDescription,
|
||
|
|
AlertDialogFooter,
|
||
|
|
AlertDialogHeader,
|
||
|
|
AlertDialogTitle,
|
||
|
|
} from "@/components/ui/alert-dialog";
|
||
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
|
|
import {
|
||
|
|
Check,
|
||
|
|
ChevronsUpDown,
|
||
|
|
Layers,
|
||
|
|
Plus,
|
||
|
|
RefreshCw,
|
||
|
|
Search,
|
||
|
|
Pencil,
|
||
|
|
Trash2,
|
||
|
|
ChevronRight,
|
||
|
|
ChevronDown,
|
||
|
|
} from "lucide-react";
|
||
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { hierarchyApi, HierarchyGroup, HierarchyLevel, HIERARCHY_TYPES } from "@/lib/api/cascadingHierarchy";
|
||
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||
|
|
|
||
|
|
export default function HierarchyTab() {
|
||
|
|
// 목록 상태
|
||
|
|
const [groups, setGroups] = useState<HierarchyGroup[]>([]);
|
||
|
|
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [searchText, setSearchText] = useState("");
|
||
|
|
|
||
|
|
// 확장된 그룹 (레벨 표시)
|
||
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||
|
|
const [groupLevels, setGroupLevels] = useState<Record<string, HierarchyLevel[]>>({});
|
||
|
|
|
||
|
|
// 모달 상태
|
||
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||
|
|
const [editingGroup, setEditingGroup] = useState<HierarchyGroup | null>(null);
|
||
|
|
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||
|
|
|
||
|
|
// 레벨 모달
|
||
|
|
const [isLevelModalOpen, setIsLevelModalOpen] = useState(false);
|
||
|
|
const [editingLevel, setEditingLevel] = useState<HierarchyLevel | null>(null);
|
||
|
|
const [currentGroupCode, setCurrentGroupCode] = useState<string>("");
|
||
|
|
const [levelColumns, setLevelColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||
|
|
|
||
|
|
// 폼 데이터
|
||
|
|
const [formData, setFormData] = useState<Partial<HierarchyGroup>>({
|
||
|
|
groupName: "",
|
||
|
|
description: "",
|
||
|
|
hierarchyType: "MULTI_TABLE",
|
||
|
|
maxLevels: undefined,
|
||
|
|
isFixedLevels: "Y",
|
||
|
|
emptyMessage: "선택해주세요",
|
||
|
|
noOptionsMessage: "옵션이 없습니다",
|
||
|
|
loadingMessage: "로딩 중...",
|
||
|
|
});
|
||
|
|
|
||
|
|
// 레벨 폼 데이터
|
||
|
|
const [levelFormData, setLevelFormData] = useState<Partial<HierarchyLevel>>({
|
||
|
|
levelOrder: 1,
|
||
|
|
levelName: "",
|
||
|
|
levelCode: "",
|
||
|
|
tableName: "",
|
||
|
|
valueColumn: "",
|
||
|
|
labelColumn: "",
|
||
|
|
parentKeyColumn: "",
|
||
|
|
orderColumn: "",
|
||
|
|
orderDirection: "ASC",
|
||
|
|
placeholder: "",
|
||
|
|
isRequired: "Y",
|
||
|
|
isSearchable: "N",
|
||
|
|
});
|
||
|
|
|
||
|
|
// 테이블 Combobox 상태
|
||
|
|
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||
|
|
|
||
|
|
// snake_case를 camelCase로 변환하는 함수
|
||
|
|
const transformGroup = (g: any): HierarchyGroup => ({
|
||
|
|
groupId: g.group_id || g.groupId,
|
||
|
|
groupCode: g.group_code || g.groupCode,
|
||
|
|
groupName: g.group_name || g.groupName,
|
||
|
|
description: g.description,
|
||
|
|
hierarchyType: g.hierarchy_type || g.hierarchyType,
|
||
|
|
maxLevels: g.max_levels || g.maxLevels,
|
||
|
|
isFixedLevels: g.is_fixed_levels || g.isFixedLevels,
|
||
|
|
selfRefTable: g.self_ref_table || g.selfRefTable,
|
||
|
|
selfRefIdColumn: g.self_ref_id_column || g.selfRefIdColumn,
|
||
|
|
selfRefParentColumn: g.self_ref_parent_column || g.selfRefParentColumn,
|
||
|
|
selfRefValueColumn: g.self_ref_value_column || g.selfRefValueColumn,
|
||
|
|
selfRefLabelColumn: g.self_ref_label_column || g.selfRefLabelColumn,
|
||
|
|
selfRefLevelColumn: g.self_ref_level_column || g.selfRefLevelColumn,
|
||
|
|
selfRefOrderColumn: g.self_ref_order_column || g.selfRefOrderColumn,
|
||
|
|
bomTable: g.bom_table || g.bomTable,
|
||
|
|
bomParentColumn: g.bom_parent_column || g.bomParentColumn,
|
||
|
|
bomChildColumn: g.bom_child_column || g.bomChildColumn,
|
||
|
|
bomItemTable: g.bom_item_table || g.bomItemTable,
|
||
|
|
bomItemIdColumn: g.bom_item_id_column || g.bomItemIdColumn,
|
||
|
|
bomItemLabelColumn: g.bom_item_label_column || g.bomItemLabelColumn,
|
||
|
|
bomQtyColumn: g.bom_qty_column || g.bomQtyColumn,
|
||
|
|
bomLevelColumn: g.bom_level_column || g.bomLevelColumn,
|
||
|
|
emptyMessage: g.empty_message || g.emptyMessage,
|
||
|
|
noOptionsMessage: g.no_options_message || g.noOptionsMessage,
|
||
|
|
loadingMessage: g.loading_message || g.loadingMessage,
|
||
|
|
companyCode: g.company_code || g.companyCode,
|
||
|
|
isActive: g.is_active || g.isActive,
|
||
|
|
createdBy: g.created_by || g.createdBy,
|
||
|
|
createdDate: g.created_date || g.createdDate,
|
||
|
|
updatedBy: g.updated_by || g.updatedBy,
|
||
|
|
updatedDate: g.updated_date || g.updatedDate,
|
||
|
|
levelCount: g.level_count || g.levelCount || 0,
|
||
|
|
levels: g.levels,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 목록 로드
|
||
|
|
const loadGroups = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const response = await hierarchyApi.getGroups();
|
||
|
|
if (response.success && response.data) {
|
||
|
|
// snake_case를 camelCase로 변환
|
||
|
|
const transformedData = response.data.map(transformGroup);
|
||
|
|
setGroups(transformedData);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("계층 그룹 목록 로드 실패:", error);
|
||
|
|
toast.error("목록을 불러오는데 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 테이블 목록 로드
|
||
|
|
const loadTables = useCallback(async () => {
|
||
|
|
try {
|
||
|
|
const response = await tableManagementApi.getTableList();
|
||
|
|
if (response.success && response.data) {
|
||
|
|
setTables(response.data);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("테이블 목록 로드 실패:", error);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadGroups();
|
||
|
|
loadTables();
|
||
|
|
}, [loadGroups, loadTables]);
|
||
|
|
|
||
|
|
// 그룹 레벨 로드
|
||
|
|
const loadGroupLevels = async (groupCode: string) => {
|
||
|
|
try {
|
||
|
|
const response = await hierarchyApi.getDetail(groupCode);
|
||
|
|
if (response.success && response.data?.levels) {
|
||
|
|
setGroupLevels((prev) => ({
|
||
|
|
...prev,
|
||
|
|
[groupCode]: response.data!.levels || [],
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("레벨 로드 실패:", error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 그룹 확장 토글
|
||
|
|
const toggleGroupExpand = async (groupCode: string) => {
|
||
|
|
const newExpanded = new Set(expandedGroups);
|
||
|
|
if (newExpanded.has(groupCode)) {
|
||
|
|
newExpanded.delete(groupCode);
|
||
|
|
} else {
|
||
|
|
newExpanded.add(groupCode);
|
||
|
|
if (!groupLevels[groupCode]) {
|
||
|
|
await loadGroupLevels(groupCode);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
setExpandedGroups(newExpanded);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 컬럼 로드 (레벨 폼용)
|
||
|
|
const loadLevelColumns = async (tableName: string) => {
|
||
|
|
if (!tableName) {
|
||
|
|
setLevelColumns([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await tableManagementApi.getColumnList(tableName);
|
||
|
|
if (response.success && response.data?.columns) {
|
||
|
|
setLevelColumns(response.data.columns);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("컬럼 목록 로드 실패:", error);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 필터된 목록
|
||
|
|
const filteredGroups = groups.filter(
|
||
|
|
(g) =>
|
||
|
|
g.groupName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||
|
|
g.groupCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||
|
|
);
|
||
|
|
|
||
|
|
// 모달 열기 (생성)
|
||
|
|
const handleOpenCreate = () => {
|
||
|
|
setEditingGroup(null);
|
||
|
|
setFormData({
|
||
|
|
groupName: "",
|
||
|
|
description: "",
|
||
|
|
hierarchyType: "MULTI_TABLE",
|
||
|
|
maxLevels: undefined,
|
||
|
|
isFixedLevels: "Y",
|
||
|
|
emptyMessage: "선택해주세요",
|
||
|
|
noOptionsMessage: "옵션이 없습니다",
|
||
|
|
loadingMessage: "로딩 중...",
|
||
|
|
});
|
||
|
|
setIsModalOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 모달 열기 (수정)
|
||
|
|
const handleOpenEdit = (group: HierarchyGroup) => {
|
||
|
|
setEditingGroup(group);
|
||
|
|
setFormData({
|
||
|
|
groupCode: group.groupCode,
|
||
|
|
groupName: group.groupName,
|
||
|
|
description: group.description || "",
|
||
|
|
hierarchyType: group.hierarchyType,
|
||
|
|
maxLevels: group.maxLevels,
|
||
|
|
isFixedLevels: group.isFixedLevels || "Y",
|
||
|
|
emptyMessage: group.emptyMessage || "선택해주세요",
|
||
|
|
noOptionsMessage: group.noOptionsMessage || "옵션이 없습니다",
|
||
|
|
loadingMessage: group.loadingMessage || "로딩 중...",
|
||
|
|
});
|
||
|
|
setIsModalOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 삭제 확인
|
||
|
|
const handleDeleteConfirm = (groupCode: string) => {
|
||
|
|
setDeletingGroupCode(groupCode);
|
||
|
|
setIsDeleteDialogOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 삭제 실행
|
||
|
|
const handleDelete = async () => {
|
||
|
|
if (!deletingGroupCode) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await hierarchyApi.deleteGroup(deletingGroupCode);
|
||
|
|
if (response.success) {
|
||
|
|
toast.success("계층 그룹이 삭제되었습니다.");
|
||
|
|
loadGroups();
|
||
|
|
} else {
|
||
|
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||
|
|
} finally {
|
||
|
|
setIsDeleteDialogOpen(false);
|
||
|
|
setDeletingGroupCode(null);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 저장
|
||
|
|
const handleSave = async () => {
|
||
|
|
if (!formData.groupName || !formData.hierarchyType) {
|
||
|
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
let response;
|
||
|
|
if (editingGroup) {
|
||
|
|
response = await hierarchyApi.updateGroup(editingGroup.groupCode!, formData);
|
||
|
|
} else {
|
||
|
|
response = await hierarchyApi.createGroup(formData as HierarchyGroup);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||
|
|
setIsModalOpen(false);
|
||
|
|
loadGroups();
|
||
|
|
} else {
|
||
|
|
toast.error(response.error || "저장에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 레벨 모달 열기 (생성)
|
||
|
|
const handleOpenCreateLevel = (groupCode: string) => {
|
||
|
|
setCurrentGroupCode(groupCode);
|
||
|
|
setEditingLevel(null);
|
||
|
|
const existingLevels = groupLevels[groupCode] || [];
|
||
|
|
setLevelFormData({
|
||
|
|
levelOrder: existingLevels.length + 1,
|
||
|
|
levelName: "",
|
||
|
|
levelCode: "",
|
||
|
|
tableName: "",
|
||
|
|
valueColumn: "",
|
||
|
|
labelColumn: "",
|
||
|
|
parentKeyColumn: "",
|
||
|
|
orderColumn: "",
|
||
|
|
orderDirection: "ASC",
|
||
|
|
placeholder: "",
|
||
|
|
isRequired: "Y",
|
||
|
|
isSearchable: "N",
|
||
|
|
});
|
||
|
|
setLevelColumns([]);
|
||
|
|
setIsLevelModalOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 레벨 모달 열기 (수정)
|
||
|
|
const handleOpenEditLevel = async (level: HierarchyLevel) => {
|
||
|
|
setCurrentGroupCode(level.groupCode);
|
||
|
|
setEditingLevel(level);
|
||
|
|
setLevelFormData({
|
||
|
|
levelOrder: level.levelOrder,
|
||
|
|
levelName: level.levelName,
|
||
|
|
levelCode: level.levelCode || "",
|
||
|
|
tableName: level.tableName,
|
||
|
|
valueColumn: level.valueColumn,
|
||
|
|
labelColumn: level.labelColumn,
|
||
|
|
parentKeyColumn: level.parentKeyColumn || "",
|
||
|
|
orderColumn: level.orderColumn || "",
|
||
|
|
orderDirection: level.orderDirection || "ASC",
|
||
|
|
placeholder: level.placeholder || "",
|
||
|
|
isRequired: level.isRequired || "Y",
|
||
|
|
isSearchable: level.isSearchable || "N",
|
||
|
|
});
|
||
|
|
await loadLevelColumns(level.tableName);
|
||
|
|
setIsLevelModalOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 레벨 저장
|
||
|
|
const handleSaveLevel = async () => {
|
||
|
|
if (
|
||
|
|
!levelFormData.levelName ||
|
||
|
|
!levelFormData.tableName ||
|
||
|
|
!levelFormData.valueColumn ||
|
||
|
|
!levelFormData.labelColumn
|
||
|
|
) {
|
||
|
|
toast.error("필수 항목을 모두 입력해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
let response;
|
||
|
|
if (editingLevel) {
|
||
|
|
response = await hierarchyApi.updateLevel(editingLevel.levelId!, levelFormData);
|
||
|
|
} else {
|
||
|
|
response = await hierarchyApi.addLevel(currentGroupCode, levelFormData);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
toast.success(editingLevel ? "레벨이 수정되었습니다." : "레벨이 추가되었습니다.");
|
||
|
|
setIsLevelModalOpen(false);
|
||
|
|
await loadGroupLevels(currentGroupCode);
|
||
|
|
} else {
|
||
|
|
toast.error(response.error || "저장에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 레벨 삭제
|
||
|
|
const handleDeleteLevel = async (levelId: number, groupCode: string) => {
|
||
|
|
if (!confirm("이 레벨을 삭제하시겠습니까?")) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await hierarchyApi.deleteLevel(levelId);
|
||
|
|
if (response.success) {
|
||
|
|
toast.success("레벨이 삭제되었습니다.");
|
||
|
|
await loadGroupLevels(groupCode);
|
||
|
|
} else {
|
||
|
|
toast.error(response.error || "삭제에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 계층 타입 라벨
|
||
|
|
const getHierarchyTypeLabel = (type: string) => {
|
||
|
|
return HIERARCHY_TYPES.find((t) => t.value === type)?.label || type;
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-6">
|
||
|
|
{/* 검색 */}
|
||
|
|
<Card>
|
||
|
|
<CardContent className="pt-6">
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<div className="relative flex-1">
|
||
|
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||
|
|
<Input
|
||
|
|
placeholder="그룹 코드, 이름으로 검색..."
|
||
|
|
value={searchText}
|
||
|
|
onChange={(e) => setSearchText(e.target.value)}
|
||
|
|
className="pl-10"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Button variant="outline" onClick={loadGroups}>
|
||
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||
|
|
새로고침
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 목록 */}
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<CardTitle className="flex items-center gap-2">
|
||
|
|
<Layers className="h-5 w-5" />
|
||
|
|
다단계 계층
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
국가 > 도시 > 구 같은 다단계 연쇄 드롭다운을 관리합니다. (총 {filteredGroups.length}개)
|
||
|
|
</CardDescription>
|
||
|
|
</div>
|
||
|
|
<Button onClick={handleOpenCreate}>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />새 계층 추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{loading ? (
|
||
|
|
<div className="flex items-center justify-center py-8">
|
||
|
|
<RefreshCw className="h-6 w-6 animate-spin" />
|
||
|
|
<span className="ml-2">로딩 중...</span>
|
||
|
|
</div>
|
||
|
|
) : filteredGroups.length === 0 ? (
|
||
|
|
<div className="text-muted-foreground space-y-4 py-8 text-center">
|
||
|
|
<div className="text-sm">{searchText ? "검색 결과가 없습니다." : "등록된 계층 그룹이 없습니다."}</div>
|
||
|
|
<div className="mx-auto max-w-md space-y-3 text-left">
|
||
|
|
<div className="rounded-lg border p-4">
|
||
|
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 지역 계층</div>
|
||
|
|
<div className="text-muted-foreground text-xs">국가 > 시/도 > 시/군/구 > 읍/면/동</div>
|
||
|
|
</div>
|
||
|
|
<div className="rounded-lg border p-4">
|
||
|
|
<div className="text-foreground mb-2 text-sm font-medium">예시: 조직 계층</div>
|
||
|
|
<div className="text-muted-foreground text-xs">본부 > 팀 > 파트 (자기 참조 구조)</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-2">
|
||
|
|
{filteredGroups.map((group) => (
|
||
|
|
<div key={group.groupCode} className="rounded-lg border">
|
||
|
|
<div
|
||
|
|
className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4"
|
||
|
|
onClick={() => toggleGroupExpand(group.groupCode)}
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
{expandedGroups.has(group.groupCode) ? (
|
||
|
|
<ChevronDown className="h-4 w-4" />
|
||
|
|
) : (
|
||
|
|
<ChevronRight className="h-4 w-4" />
|
||
|
|
)}
|
||
|
|
<div>
|
||
|
|
<div className="font-medium">{group.groupName}</div>
|
||
|
|
<div className="text-muted-foreground text-xs">{group.groupCode}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-4">
|
||
|
|
<Badge variant="outline">{getHierarchyTypeLabel(group.hierarchyType)}</Badge>
|
||
|
|
<Badge variant="secondary">{group.levelCount || 0}개 레벨</Badge>
|
||
|
|
<Badge variant={group.isActive === "Y" ? "default" : "secondary"}>
|
||
|
|
{group.isActive === "Y" ? "활성" : "비활성"}
|
||
|
|
</Badge>
|
||
|
|
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||
|
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(group)}>
|
||
|
|
<Pencil className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(group.groupCode)}>
|
||
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 레벨 목록 */}
|
||
|
|
{expandedGroups.has(group.groupCode) && (
|
||
|
|
<div className="bg-muted/20 border-t p-4">
|
||
|
|
<div className="mb-3 flex items-center justify-between">
|
||
|
|
<span className="text-sm font-medium">레벨 목록</span>
|
||
|
|
<Button size="sm" variant="outline" onClick={() => handleOpenCreateLevel(group.groupCode)}>
|
||
|
|
<Plus className="mr-1 h-3 w-3" />
|
||
|
|
레벨 추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
{(groupLevels[group.groupCode] || []).length === 0 ? (
|
||
|
|
<div className="text-muted-foreground py-4 text-center text-sm">등록된 레벨이 없습니다.</div>
|
||
|
|
) : (
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead className="w-16">순서</TableHead>
|
||
|
|
<TableHead>레벨명</TableHead>
|
||
|
|
<TableHead>테이블</TableHead>
|
||
|
|
<TableHead>값 컬럼</TableHead>
|
||
|
|
<TableHead>부모 키</TableHead>
|
||
|
|
<TableHead className="text-right">작업</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{(groupLevels[group.groupCode] || []).map((level) => (
|
||
|
|
<TableRow key={level.levelId}>
|
||
|
|
<TableCell>{level.levelOrder}</TableCell>
|
||
|
|
<TableCell className="font-medium">{level.levelName}</TableCell>
|
||
|
|
<TableCell className="font-mono text-xs">{level.tableName}</TableCell>
|
||
|
|
<TableCell className="font-mono text-xs">{level.valueColumn}</TableCell>
|
||
|
|
<TableCell className="font-mono text-xs">{level.parentKeyColumn || "-"}</TableCell>
|
||
|
|
<TableCell className="text-right">
|
||
|
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEditLevel(level)}>
|
||
|
|
<Pencil className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
onClick={() => handleDeleteLevel(level.levelId!, group.groupCode)}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||
|
|
</Button>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 그룹 생성/수정 모달 */}
|
||
|
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||
|
|
<DialogContent className="max-w-lg">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{editingGroup ? "계층 그룹 수정" : "계층 그룹 생성"}</DialogTitle>
|
||
|
|
<DialogDescription>다단계 연쇄 드롭다운의 기본 정보를 설정합니다.</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>그룹명 *</Label>
|
||
|
|
<Input
|
||
|
|
value={formData.groupName}
|
||
|
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
||
|
|
placeholder="예: 지역 계층"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>계층 유형 *</Label>
|
||
|
|
<Select
|
||
|
|
value={formData.hierarchyType}
|
||
|
|
onValueChange={(v: any) => setFormData({ ...formData, hierarchyType: v })}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{HIERARCHY_TYPES.map((t) => (
|
||
|
|
<SelectItem key={t.value} value={t.value}>
|
||
|
|
{t.label}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>설명</Label>
|
||
|
|
<Textarea
|
||
|
|
value={formData.description}
|
||
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
|
|
placeholder="계층 구조에 대한 설명"
|
||
|
|
rows={2}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>최대 레벨 수</Label>
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
value={formData.maxLevels || ""}
|
||
|
|
onChange={(e) =>
|
||
|
|
setFormData({ ...formData, maxLevels: e.target.value ? Number(e.target.value) : undefined })
|
||
|
|
}
|
||
|
|
placeholder="예: 4"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>고정 레벨 여부</Label>
|
||
|
|
<Select
|
||
|
|
value={formData.isFixedLevels}
|
||
|
|
onValueChange={(v) => setFormData({ ...formData, isFixedLevels: v })}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="Y">고정</SelectItem>
|
||
|
|
<SelectItem value="N">가변</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
{/* 레벨 생성/수정 모달 */}
|
||
|
|
<Dialog open={isLevelModalOpen} onOpenChange={setIsLevelModalOpen}>
|
||
|
|
<DialogContent className="max-w-lg">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{editingLevel ? "레벨 수정" : "레벨 추가"}</DialogTitle>
|
||
|
|
<DialogDescription>계층의 개별 레벨 정보를 설정합니다.</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>레벨 순서 *</Label>
|
||
|
|
<Input
|
||
|
|
type="number"
|
||
|
|
value={levelFormData.levelOrder}
|
||
|
|
onChange={(e) => setLevelFormData({ ...levelFormData, levelOrder: Number(e.target.value) })}
|
||
|
|
min={1}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>레벨명 *</Label>
|
||
|
|
<Input
|
||
|
|
value={levelFormData.levelName}
|
||
|
|
onChange={(e) => setLevelFormData({ ...levelFormData, levelName: e.target.value })}
|
||
|
|
placeholder="예: 시/도"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>테이블 *</Label>
|
||
|
|
<Popover open={tableComboOpen} onOpenChange={setTableComboOpen}>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
role="combobox"
|
||
|
|
aria-expanded={tableComboOpen}
|
||
|
|
className="h-10 w-full justify-between text-sm"
|
||
|
|
>
|
||
|
|
{levelFormData.tableName
|
||
|
|
? tables.find((t) => t.tableName === levelFormData.tableName)?.displayName ||
|
||
|
|
levelFormData.tableName
|
||
|
|
: "테이블 선택"}
|
||
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||
|
|
</Button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||
|
|
<Command>
|
||
|
|
<CommandInput placeholder="테이블명 또는 라벨로 검색..." className="text-sm" />
|
||
|
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
||
|
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||
|
|
<CommandGroup>
|
||
|
|
{tables.map((t) => (
|
||
|
|
<CommandItem
|
||
|
|
key={t.tableName}
|
||
|
|
value={`${t.tableName} ${t.displayName || ""}`}
|
||
|
|
onSelect={async () => {
|
||
|
|
setLevelFormData({
|
||
|
|
...levelFormData,
|
||
|
|
tableName: t.tableName,
|
||
|
|
valueColumn: "",
|
||
|
|
labelColumn: "",
|
||
|
|
parentKeyColumn: "",
|
||
|
|
});
|
||
|
|
await loadLevelColumns(t.tableName);
|
||
|
|
setTableComboOpen(false);
|
||
|
|
}}
|
||
|
|
className="text-sm"
|
||
|
|
>
|
||
|
|
<Check
|
||
|
|
className={cn(
|
||
|
|
"mr-2 h-4 w-4",
|
||
|
|
levelFormData.tableName === t.tableName ? "opacity-100" : "opacity-0",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<div className="flex flex-col">
|
||
|
|
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||
|
|
{t.displayName && t.displayName !== t.tableName && (
|
||
|
|
<span className="text-muted-foreground text-xs">{t.tableName}</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</CommandItem>
|
||
|
|
))}
|
||
|
|
</CommandGroup>
|
||
|
|
</CommandList>
|
||
|
|
</Command>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-2 gap-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>값 컬럼 *</Label>
|
||
|
|
<Select
|
||
|
|
value={levelFormData.valueColumn}
|
||
|
|
onValueChange={(v) => setLevelFormData({ ...levelFormData, valueColumn: v })}
|
||
|
|
disabled={!levelFormData.tableName}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{levelColumns.map((c) => (
|
||
|
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||
|
|
{c.displayName || c.columnName}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>라벨 컬럼 *</Label>
|
||
|
|
<Select
|
||
|
|
value={levelFormData.labelColumn}
|
||
|
|
onValueChange={(v) => setLevelFormData({ ...levelFormData, labelColumn: v })}
|
||
|
|
disabled={!levelFormData.tableName}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{levelColumns.map((c) => (
|
||
|
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||
|
|
{c.displayName || c.columnName}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>부모 키 컬럼 (레벨 2 이상)</Label>
|
||
|
|
<Select
|
||
|
|
value={levelFormData.parentKeyColumn || "__none__"}
|
||
|
|
onValueChange={(v) =>
|
||
|
|
setLevelFormData({ ...levelFormData, parentKeyColumn: v === "__none__" ? "" : v })
|
||
|
|
}
|
||
|
|
disabled={!levelFormData.tableName}
|
||
|
|
>
|
||
|
|
<SelectTrigger>
|
||
|
|
<SelectValue placeholder="선택 (선택사항)" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="__none__">없음</SelectItem>
|
||
|
|
{levelColumns.map((c) => (
|
||
|
|
<SelectItem key={c.columnName} value={c.columnName}>
|
||
|
|
{c.displayName || c.columnName}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<p className="text-muted-foreground text-xs">상위 레벨의 선택 값을 참조하는 컬럼입니다.</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>플레이스홀더</Label>
|
||
|
|
<Input
|
||
|
|
value={levelFormData.placeholder}
|
||
|
|
onChange={(e) => setLevelFormData({ ...levelFormData, placeholder: e.target.value })}
|
||
|
|
placeholder={`${levelFormData.levelName || "레벨"} 선택`}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={() => setIsLevelModalOpen(false)}>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleSaveLevel}>{editingLevel ? "수정" : "추가"}</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
{/* 삭제 확인 다이얼로그 */}
|
||
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||
|
|
<AlertDialogContent>
|
||
|
|
<AlertDialogHeader>
|
||
|
|
<AlertDialogTitle>계층 그룹 삭제</AlertDialogTitle>
|
||
|
|
<AlertDialogDescription>
|
||
|
|
이 계층 그룹과 모든 레벨을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||
|
|
</AlertDialogDescription>
|
||
|
|
</AlertDialogHeader>
|
||
|
|
<AlertDialogFooter>
|
||
|
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||
|
|
<AlertDialogAction onClick={handleDelete} className="bg-red-500 hover:bg-red-600">
|
||
|
|
삭제
|
||
|
|
</AlertDialogAction>
|
||
|
|
</AlertDialogFooter>
|
||
|
|
</AlertDialogContent>
|
||
|
|
</AlertDialog>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|