ERP-node/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx

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>
);
}