627 lines
22 KiB
TypeScript
627 lines
22 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 { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
|
import {
|
|
hierarchyColumnApi,
|
|
HierarchyColumnGroup,
|
|
CreateHierarchyGroupRequest,
|
|
} from "@/lib/api/hierarchyColumn";
|
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
|
import apiClient from "@/lib/api/client";
|
|
|
|
interface TableInfo {
|
|
tableName: string;
|
|
displayName?: string;
|
|
}
|
|
|
|
interface ColumnInfo {
|
|
columnName: string;
|
|
displayName?: string;
|
|
dataType?: string;
|
|
}
|
|
|
|
interface CategoryInfo {
|
|
categoryCode: string;
|
|
categoryName: string;
|
|
}
|
|
|
|
export default function HierarchyColumnTab() {
|
|
// 상태
|
|
const [groups, setGroups] = useState<HierarchyColumnGroup[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [selectedGroup, setSelectedGroup] = useState<HierarchyColumnGroup | null>(null);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
|
|
// 폼 상태
|
|
const [formData, setFormData] = useState({
|
|
groupCode: "",
|
|
groupName: "",
|
|
description: "",
|
|
codeCategory: "",
|
|
tableName: "",
|
|
maxDepth: 3,
|
|
mappings: [
|
|
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
|
|
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
|
|
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
|
|
],
|
|
});
|
|
|
|
// 참조 데이터
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
|
const [categories, setCategories] = useState<CategoryInfo[]>([]);
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
|
const [loadingCategories, setLoadingCategories] = useState(false);
|
|
|
|
// 그룹 목록 로드
|
|
const loadGroups = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await hierarchyColumnApi.getAll();
|
|
if (response.success && response.data) {
|
|
setGroups(response.data);
|
|
} else {
|
|
toast.error(response.error || "계층구조 그룹 로드 실패");
|
|
}
|
|
} catch (error) {
|
|
console.error("계층구조 그룹 로드 에러:", error);
|
|
toast.error("계층구조 그룹을 로드하는 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// 테이블 목록 로드
|
|
const loadTables = useCallback(async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
const response = await apiClient.get("/table-management/tables");
|
|
if (response.data?.success && response.data?.data) {
|
|
setTables(response.data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 로드 에러:", error);
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
}, []);
|
|
|
|
// 카테고리 목록 로드
|
|
const loadCategories = useCallback(async () => {
|
|
setLoadingCategories(true);
|
|
try {
|
|
const response = await commonCodeApi.categories.getList();
|
|
if (response.success && response.data) {
|
|
setCategories(
|
|
response.data.map((cat: any) => ({
|
|
categoryCode: cat.categoryCode || cat.category_code,
|
|
categoryName: cat.categoryName || cat.category_name,
|
|
}))
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 로드 에러:", error);
|
|
} finally {
|
|
setLoadingCategories(false);
|
|
}
|
|
}, []);
|
|
|
|
// 테이블 선택 시 컬럼 로드
|
|
const loadColumns = useCallback(async (tableName: string) => {
|
|
if (!tableName) {
|
|
setColumns([]);
|
|
return;
|
|
}
|
|
setLoadingColumns(true);
|
|
try {
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
if (response.data?.success && response.data?.data) {
|
|
setColumns(response.data.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("컬럼 로드 에러:", error);
|
|
} finally {
|
|
setLoadingColumns(false);
|
|
}
|
|
}, []);
|
|
|
|
// 초기 로드
|
|
useEffect(() => {
|
|
loadGroups();
|
|
loadTables();
|
|
loadCategories();
|
|
}, [loadGroups, loadTables, loadCategories]);
|
|
|
|
// 테이블 선택 변경 시 컬럼 로드
|
|
useEffect(() => {
|
|
if (formData.tableName) {
|
|
loadColumns(formData.tableName);
|
|
}
|
|
}, [formData.tableName, loadColumns]);
|
|
|
|
// 폼 초기화
|
|
const resetForm = () => {
|
|
setFormData({
|
|
groupCode: "",
|
|
groupName: "",
|
|
description: "",
|
|
codeCategory: "",
|
|
tableName: "",
|
|
maxDepth: 3,
|
|
mappings: [
|
|
{ depth: 1, levelLabel: "대분류", columnName: "", placeholder: "대분류 선택", isRequired: true },
|
|
{ depth: 2, levelLabel: "중분류", columnName: "", placeholder: "중분류 선택", isRequired: false },
|
|
{ depth: 3, levelLabel: "소분류", columnName: "", placeholder: "소분류 선택", isRequired: false },
|
|
],
|
|
});
|
|
setSelectedGroup(null);
|
|
setIsEditing(false);
|
|
};
|
|
|
|
// 모달 열기 (신규)
|
|
const openCreateModal = () => {
|
|
resetForm();
|
|
setModalOpen(true);
|
|
};
|
|
|
|
// 모달 열기 (수정)
|
|
const openEditModal = (group: HierarchyColumnGroup) => {
|
|
setSelectedGroup(group);
|
|
setIsEditing(true);
|
|
|
|
// 매핑 데이터 변환
|
|
const mappings = [1, 2, 3].map((depth) => {
|
|
const existing = group.mappings?.find((m) => m.depth === depth);
|
|
return {
|
|
depth,
|
|
levelLabel: existing?.level_label || (depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"),
|
|
columnName: existing?.column_name || "",
|
|
placeholder: existing?.placeholder || `${depth === 1 ? "대분류" : depth === 2 ? "중분류" : "소분류"} 선택`,
|
|
isRequired: existing?.is_required === "Y",
|
|
};
|
|
});
|
|
|
|
setFormData({
|
|
groupCode: group.group_code,
|
|
groupName: group.group_name,
|
|
description: group.description || "",
|
|
codeCategory: group.code_category,
|
|
tableName: group.table_name,
|
|
maxDepth: group.max_depth,
|
|
mappings,
|
|
});
|
|
|
|
// 컬럼 로드
|
|
loadColumns(group.table_name);
|
|
setModalOpen(true);
|
|
};
|
|
|
|
// 삭제 확인 열기
|
|
const openDeleteDialog = (group: HierarchyColumnGroup) => {
|
|
setSelectedGroup(group);
|
|
setDeleteDialogOpen(true);
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = async () => {
|
|
// 필수 필드 검증
|
|
if (!formData.groupCode || !formData.groupName || !formData.codeCategory || !formData.tableName) {
|
|
toast.error("필수 필드를 모두 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
// 최소 1개 컬럼 매핑 검증
|
|
const validMappings = formData.mappings
|
|
.filter((m) => m.depth <= formData.maxDepth && m.columnName)
|
|
.map((m) => ({
|
|
depth: m.depth,
|
|
levelLabel: m.levelLabel,
|
|
columnName: m.columnName,
|
|
placeholder: m.placeholder,
|
|
isRequired: m.isRequired,
|
|
}));
|
|
|
|
if (validMappings.length === 0) {
|
|
toast.error("최소 하나의 컬럼 매핑이 필요합니다.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (isEditing && selectedGroup) {
|
|
// 수정
|
|
const response = await hierarchyColumnApi.update(selectedGroup.group_id, {
|
|
groupName: formData.groupName,
|
|
description: formData.description,
|
|
maxDepth: formData.maxDepth,
|
|
mappings: validMappings,
|
|
});
|
|
|
|
if (response.success) {
|
|
toast.success("계층구조 그룹이 수정되었습니다.");
|
|
setModalOpen(false);
|
|
loadGroups();
|
|
} else {
|
|
toast.error(response.error || "수정 실패");
|
|
}
|
|
} else {
|
|
// 생성
|
|
const request: CreateHierarchyGroupRequest = {
|
|
groupCode: formData.groupCode,
|
|
groupName: formData.groupName,
|
|
description: formData.description,
|
|
codeCategory: formData.codeCategory,
|
|
tableName: formData.tableName,
|
|
maxDepth: formData.maxDepth,
|
|
mappings: validMappings,
|
|
};
|
|
|
|
const response = await hierarchyColumnApi.create(request);
|
|
|
|
if (response.success) {
|
|
toast.success("계층구조 그룹이 생성되었습니다.");
|
|
setModalOpen(false);
|
|
loadGroups();
|
|
} else {
|
|
toast.error(response.error || "생성 실패");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("저장 에러:", error);
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
// 삭제
|
|
const handleDelete = async () => {
|
|
if (!selectedGroup) return;
|
|
|
|
try {
|
|
const response = await hierarchyColumnApi.delete(selectedGroup.group_id);
|
|
if (response.success) {
|
|
toast.success("계층구조 그룹이 삭제되었습니다.");
|
|
setDeleteDialogOpen(false);
|
|
loadGroups();
|
|
} else {
|
|
toast.error(response.error || "삭제 실패");
|
|
}
|
|
} catch (error) {
|
|
console.error("삭제 에러:", error);
|
|
toast.error("삭제 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
// 매핑 컬럼 변경
|
|
const handleMappingChange = (depth: number, field: string, value: any) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
mappings: prev.mappings.map((m) =>
|
|
m.depth === depth ? { ...m, [field]: value } : m
|
|
),
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold">계층구조 컬럼 그룹</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
공통코드 계층구조를 테이블 컬럼에 매핑하여 대분류/중분류/소분류를 각각 별도 컬럼에 저장합니다.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={loadGroups} disabled={loading}>
|
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
|
새로고침
|
|
</Button>
|
|
<Button size="sm" onClick={openCreateModal}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
새 그룹 추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 그룹 목록 */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<LoadingSpinner />
|
|
<span className="ml-2 text-muted-foreground">로딩 중...</span>
|
|
</div>
|
|
) : groups.length === 0 ? (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
<Layers className="h-12 w-12 text-muted-foreground" />
|
|
<p className="mt-4 text-muted-foreground">계층구조 컬럼 그룹이 없습니다.</p>
|
|
<Button className="mt-4" onClick={openCreateModal}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
첫 번째 그룹 만들기
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{groups.map((group) => (
|
|
<Card key={group.group_id} className="hover:shadow-md transition-shadow">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<CardTitle className="text-base">{group.group_name}</CardTitle>
|
|
<CardDescription className="text-xs">{group.group_code}</CardDescription>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEditModal(group)}>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => openDeleteDialog(group)}>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex items-center gap-2 text-sm">
|
|
<Database className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-medium">{group.table_name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">{group.code_category}</Badge>
|
|
<Badge variant="secondary">{group.max_depth}단계</Badge>
|
|
</div>
|
|
{group.mappings && group.mappings.length > 0 && (
|
|
<div className="space-y-1">
|
|
{group.mappings.map((mapping) => (
|
|
<div key={mapping.depth} className="flex items-center gap-2 text-xs">
|
|
<Badge variant="outline" className="w-14 justify-center">
|
|
{mapping.level_label}
|
|
</Badge>
|
|
<span className="font-mono text-muted-foreground">{mapping.column_name}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 생성/수정 모달 */}
|
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
<DialogContent className="max-w-[600px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"}</DialogTitle>
|
|
<DialogDescription>
|
|
공통코드 계층구조를 테이블 컬럼에 매핑합니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-4">
|
|
{/* 기본 정보 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>그룹 코드 *</Label>
|
|
<Input
|
|
value={formData.groupCode}
|
|
onChange={(e) => setFormData({ ...formData, groupCode: e.target.value.toUpperCase() })}
|
|
placeholder="예: ITEM_CAT_HIERARCHY"
|
|
disabled={isEditing}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>그룹명 *</Label>
|
|
<Input
|
|
value={formData.groupName}
|
|
onChange={(e) => setFormData({ ...formData, groupName: e.target.value })}
|
|
placeholder="예: 품목분류 계층"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>설명</Label>
|
|
<Input
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
placeholder="계층구조에 대한 설명"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>공통코드 카테고리 *</Label>
|
|
<Select
|
|
value={formData.codeCategory}
|
|
onValueChange={(value) => setFormData({ ...formData, codeCategory: value })}
|
|
disabled={isEditing}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="카테고리 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{loadingCategories ? (
|
|
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
|
) : (
|
|
categories.map((cat) => (
|
|
<SelectItem key={cat.categoryCode} value={cat.categoryCode}>
|
|
{cat.categoryName} ({cat.categoryCode})
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>적용 테이블 *</Label>
|
|
<Select
|
|
value={formData.tableName}
|
|
onValueChange={(value) => setFormData({ ...formData, tableName: value })}
|
|
disabled={isEditing}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{loadingTables ? (
|
|
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
|
) : (
|
|
tables.map((table) => (
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
|
{table.displayName || table.tableName}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>최대 깊이</Label>
|
|
<Select
|
|
value={String(formData.maxDepth)}
|
|
onValueChange={(value) => setFormData({ ...formData, maxDepth: Number(value) })}
|
|
>
|
|
<SelectTrigger className="w-40">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="1">1단계 (대분류만)</SelectItem>
|
|
<SelectItem value="2">2단계 (대/중분류)</SelectItem>
|
|
<SelectItem value="3">3단계 (대/중/소분류)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 컬럼 매핑 */}
|
|
<div className="space-y-3 border-t pt-4">
|
|
<Label className="text-base font-medium">컬럼 매핑</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
각 계층 레벨에 저장할 컬럼을 선택합니다.
|
|
</p>
|
|
|
|
{formData.mappings
|
|
.filter((m) => m.depth <= formData.maxDepth)
|
|
.map((mapping) => (
|
|
<div key={mapping.depth} className="grid grid-cols-4 gap-2 items-center">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant={mapping.depth === 1 ? "default" : "outline"}>
|
|
{mapping.depth}단계
|
|
</Badge>
|
|
<Input
|
|
value={mapping.levelLabel}
|
|
onChange={(e) => handleMappingChange(mapping.depth, "levelLabel", e.target.value)}
|
|
className="h-8 text-xs"
|
|
placeholder="라벨"
|
|
/>
|
|
</div>
|
|
<Select
|
|
value={mapping.columnName || "_none"}
|
|
onValueChange={(value) => handleMappingChange(mapping.depth, "columnName", value === "_none" ? "" : value)}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_none">컬럼 선택</SelectItem>
|
|
{loadingColumns ? (
|
|
<SelectItem value="_loading" disabled>로딩 중...</SelectItem>
|
|
) : (
|
|
columns.map((col) => (
|
|
<SelectItem key={col.columnName} value={col.columnName}>
|
|
{col.displayName || col.columnName}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
value={mapping.placeholder}
|
|
onChange={(e) => handleMappingChange(mapping.depth, "placeholder", e.target.value)}
|
|
className="h-8 text-xs"
|
|
placeholder="플레이스홀더"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={mapping.isRequired}
|
|
onChange={(e) => handleMappingChange(mapping.depth, "isRequired", e.target.checked)}
|
|
className="h-4 w-4"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">필수</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
{isEditing ? "수정" : "생성"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>계층구조 그룹 삭제</DialogTitle>
|
|
<DialogDescription>
|
|
"{selectedGroup?.group_name}" 그룹을 삭제하시겠습니까?
|
|
<br />
|
|
이 작업은 되돌릴 수 없습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
|
취소
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleDelete}>
|
|
삭제
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|