연쇄 통합관리
This commit is contained in:
104
frontend/app/(main)/admin/cascading-management/page.tsx
Normal file
104
frontend/app/(main)/admin/cascading-management/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react";
|
||||
|
||||
// 탭별 컴포넌트
|
||||
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||
import AutoFillTab from "./tabs/AutoFillTab";
|
||||
import HierarchyTab from "./tabs/HierarchyTab";
|
||||
import ConditionTab from "./tabs/ConditionTab";
|
||||
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||
|
||||
export default function CascadingManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState("relations");
|
||||
|
||||
// URL 쿼리 파라미터에서 탭 설정
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) {
|
||||
setActiveTab(tab);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 탭 변경 시 URL 업데이트
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", value);
|
||||
router.replace(url.pathname + url.search);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">연쇄 드롭다운 통합 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
연쇄 드롭다운, 자동 입력, 다단계 계층, 조건부 필터 등을 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 탭 네비게이션 */}
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="relations" className="gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||
<span className="sm:hidden">연쇄</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="hierarchy" className="gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">다단계 계층</span>
|
||||
<span className="sm:hidden">계층</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="condition" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">조건부 필터</span>
|
||||
<span className="sm:hidden">조건</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="autofill" className="gap-2">
|
||||
<FormInput className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">자동 입력</span>
|
||||
<span className="sm:hidden">자동</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="exclusion" className="gap-2">
|
||||
<Ban className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">상호 배제</span>
|
||||
<span className="sm:hidden">배제</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="mt-6">
|
||||
<TabsContent value="relations">
|
||||
<CascadingRelationsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hierarchy">
|
||||
<HierarchyTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="condition">
|
||||
<ConditionTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="autofill">
|
||||
<AutoFillTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="exclusion">
|
||||
<MutualExclusionTab />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
"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 { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ArrowRight,
|
||||
X,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
interface TableColumn {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
dataType?: string;
|
||||
}
|
||||
|
||||
export default function AutoFillTab() {
|
||||
// 목록 상태
|
||||
const [groups, setGroups] = useState<AutoFillGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<AutoFillGroup | null>(null);
|
||||
const [deletingGroupCode, setDeletingGroupCode] = useState<string | null>(null);
|
||||
|
||||
// 테이블/컬럼 목록
|
||||
const [tableList, setTableList] = useState<Array<{ tableName: string; displayName?: string }>>([]);
|
||||
const [masterColumns, setMasterColumns] = useState<TableColumn[]>([]);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState({
|
||||
groupName: "",
|
||||
description: "",
|
||||
masterTable: "",
|
||||
masterValueColumn: "",
|
||||
masterLabelColumn: "",
|
||||
isActive: "Y",
|
||||
});
|
||||
|
||||
// 매핑 데이터
|
||||
const [mappings, setMappings] = useState<AutoFillMapping[]>([]);
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||
|
||||
// 목록 로드
|
||||
const loadGroups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await cascadingAutoFillApi.getGroups();
|
||||
if (response.success && response.data) {
|
||||
setGroups(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("그룹 목록 로드 실패:", error);
|
||||
toast.error("그룹 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 로드
|
||||
const loadTableList = useCallback(async () => {
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setTableList(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 컬럼 로드
|
||||
const loadColumns = useCallback(async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setMasterColumns([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data?.columns) {
|
||||
setMasterColumns(
|
||||
response.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.columnLabel || col.column_label || col.columnName,
|
||||
dataType: col.dataType || col.data_type,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
setMasterColumns([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
loadTableList();
|
||||
}, [loadGroups, loadTableList]);
|
||||
|
||||
// 테이블 변경 시 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (formData.masterTable) {
|
||||
loadColumns(formData.masterTable);
|
||||
}
|
||||
}, [formData.masterTable, loadColumns]);
|
||||
|
||||
// 필터된 목록
|
||||
const filteredGroups = groups.filter(
|
||||
(g) =>
|
||||
g.groupCode.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
g.groupName.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
g.masterTable?.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingGroup(null);
|
||||
setFormData({
|
||||
groupName: "",
|
||||
description: "",
|
||||
masterTable: "",
|
||||
masterValueColumn: "",
|
||||
masterLabelColumn: "",
|
||||
isActive: "Y",
|
||||
});
|
||||
setMappings([]);
|
||||
setMasterColumns([]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = async (group: AutoFillGroup) => {
|
||||
setEditingGroup(group);
|
||||
|
||||
// 상세 정보 로드
|
||||
const detailResponse = await cascadingAutoFillApi.getGroupDetail(group.groupCode);
|
||||
if (detailResponse.success && detailResponse.data) {
|
||||
const detail = detailResponse.data;
|
||||
|
||||
// 컬럼 먼저 로드
|
||||
if (detail.masterTable) {
|
||||
await loadColumns(detail.masterTable);
|
||||
}
|
||||
|
||||
setFormData({
|
||||
groupCode: detail.groupCode,
|
||||
groupName: detail.groupName,
|
||||
description: detail.description || "",
|
||||
masterTable: detail.masterTable,
|
||||
masterValueColumn: detail.masterValueColumn,
|
||||
masterLabelColumn: detail.masterLabelColumn || "",
|
||||
isActive: detail.isActive || "Y",
|
||||
});
|
||||
|
||||
// 매핑 데이터 변환 (snake_case → camelCase)
|
||||
const convertedMappings = (detail.mappings || []).map((m: any) => ({
|
||||
sourceColumn: m.source_column || m.sourceColumn,
|
||||
targetField: m.target_field || m.targetField,
|
||||
targetLabel: m.target_label || m.targetLabel || "",
|
||||
isEditable: m.is_editable || m.isEditable || "Y",
|
||||
isRequired: m.is_required || m.isRequired || "N",
|
||||
defaultValue: m.default_value || m.defaultValue || "",
|
||||
sortOrder: m.sort_order || m.sortOrder || 0,
|
||||
}));
|
||||
setMappings(convertedMappings);
|
||||
}
|
||||
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (groupCode: string) => {
|
||||
setDeletingGroupCode(groupCode);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingGroupCode) return;
|
||||
|
||||
try {
|
||||
const response = await cascadingAutoFillApi.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.masterTable || !formData.masterValueColumn) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saveData = {
|
||||
...formData,
|
||||
mappings,
|
||||
};
|
||||
|
||||
let response;
|
||||
if (editingGroup) {
|
||||
response = await cascadingAutoFillApi.updateGroup(editingGroup.groupCode!, saveData);
|
||||
} else {
|
||||
response = await cascadingAutoFillApi.createGroup(saveData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingGroup ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadGroups();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑 추가
|
||||
const handleAddMapping = () => {
|
||||
setMappings([
|
||||
...mappings,
|
||||
{
|
||||
sourceColumn: "",
|
||||
targetField: "",
|
||||
targetLabel: "",
|
||||
isEditable: "Y",
|
||||
isRequired: "N",
|
||||
defaultValue: "",
|
||||
sortOrder: mappings.length + 1,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const handleRemoveMapping = (index: number) => {
|
||||
setMappings(mappings.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 매핑 수정
|
||||
const handleMappingChange = (index: number, field: keyof AutoFillMapping, value: any) => {
|
||||
const updated = [...mappings];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setMappings(updated);
|
||||
};
|
||||
|
||||
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>자동 입력 그룹</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 py-8 text-center">
|
||||
{searchText ? "검색 결과가 없습니다." : "등록된 자동 입력 그룹이 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>그룹 코드</TableHead>
|
||||
<TableHead>그룹명</TableHead>
|
||||
<TableHead>마스터 테이블</TableHead>
|
||||
<TableHead>매핑 수</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredGroups.map((group) => (
|
||||
<TableRow key={group.groupCode}>
|
||||
<TableCell className="font-mono text-sm">{group.groupCode}</TableCell>
|
||||
<TableCell className="font-medium">{group.groupName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{group.masterTable}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.mappingCount || 0}개</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={group.isActive === "Y" ? "default" : "outline"}>
|
||||
{group.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingGroup ? "자동 입력 그룹 수정" : "자동 입력 그룹 생성"}</DialogTitle>
|
||||
<DialogDescription>마스터 데이터 선택 시 자동으로 입력할 필드들을 설정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<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>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="이 자동 입력 그룹에 대한 설명"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={formData.isActive === "Y"}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked ? "Y" : "N" })}
|
||||
/>
|
||||
<Label>활성화</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 마스터 테이블 설정 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">마스터 테이블 설정</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
사용자가 선택할 마스터 데이터의 테이블과 컬럼을 지정합니다.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<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"
|
||||
>
|
||||
{formData.masterTable
|
||||
? tableList.find((t) => t.tableName === formData.masterTable)?.displayName ||
|
||||
formData.masterTable
|
||||
: "테이블 선택"}
|
||||
<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>
|
||||
{tableList.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.displayName || ""}`}
|
||||
onSelect={() => {
|
||||
setFormData({
|
||||
...formData,
|
||||
masterTable: table.tableName,
|
||||
masterValueColumn: "",
|
||||
masterLabelColumn: "",
|
||||
});
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.masterTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.displayName || table.tableName}</span>
|
||||
{table.displayName && table.displayName !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>값 컬럼 *</Label>
|
||||
<Select
|
||||
value={formData.masterValueColumn}
|
||||
onValueChange={(value) => setFormData({ ...formData, masterValueColumn: value })}
|
||||
disabled={!formData.masterTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="값 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{masterColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>라벨 컬럼</Label>
|
||||
<Select
|
||||
value={formData.masterLabelColumn}
|
||||
onValueChange={(value) => setFormData({ ...formData, masterLabelColumn: value })}
|
||||
disabled={!formData.masterTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="라벨 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{masterColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">필드 매핑</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
마스터 테이블의 컬럼을 폼의 어떤 필드에 자동 입력할지 설정합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleAddMapping}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed py-8 text-center text-sm">
|
||||
매핑이 없습니다. "매핑 추가" 버튼을 클릭하여 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="bg-muted/30 flex items-center gap-3 rounded-lg border p-3">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
|
||||
|
||||
{/* 소스 컬럼 */}
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={mapping.sourceColumn}
|
||||
onValueChange={(value) => handleMappingChange(index, "sourceColumn", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="소스 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{masterColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="text-muted-foreground h-4 w-4" />
|
||||
|
||||
{/* 타겟 필드 */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={mapping.targetField}
|
||||
onChange={(e) => handleMappingChange(index, "targetField", e.target.value)}
|
||||
placeholder="타겟 필드명 (예: contact_name)"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 타겟 라벨 */}
|
||||
<div className="w-28">
|
||||
<Input
|
||||
value={mapping.targetLabel || ""}
|
||||
onChange={(e) => handleMappingChange(index, "targetLabel", e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Checkbox
|
||||
id={`editable-${index}`}
|
||||
checked={mapping.isEditable === "Y"}
|
||||
onCheckedChange={(checked) => handleMappingChange(index, "isEditable", checked ? "Y" : "N")}
|
||||
/>
|
||||
<Label htmlFor={`editable-${index}`} className="text-xs">
|
||||
수정
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={mapping.isRequired === "Y"}
|
||||
onCheckedChange={(checked) => handleMappingChange(index, "isRequired", checked ? "Y" : "N")}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="text-xs">
|
||||
필수
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingGroup ? "수정" : "생성"}</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Link2,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
interface TableInfo {
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
columnName: string;
|
||||
columnLabel?: string;
|
||||
}
|
||||
|
||||
export default function CascadingRelationsTab() {
|
||||
// 목록 상태
|
||||
const [relations, setRelations] = useState<CascadingRelation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingRelation, setEditingRelation] = useState<CascadingRelation | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 테이블/컬럼 목록
|
||||
const [tableList, setTableList] = useState<TableInfo[]>([]);
|
||||
const [parentColumns, setParentColumns] = useState<ColumnInfo[]>([]);
|
||||
const [childColumns, setChildColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingParentColumns, setLoadingParentColumns] = useState(false);
|
||||
const [loadingChildColumns, setLoadingChildColumns] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState<CascadingRelationCreateInput>({
|
||||
relationCode: "",
|
||||
relationName: "",
|
||||
description: "",
|
||||
parentTable: "",
|
||||
parentValueColumn: "",
|
||||
parentLabelColumn: "",
|
||||
childTable: "",
|
||||
childFilterColumn: "",
|
||||
childValueColumn: "",
|
||||
childLabelColumn: "",
|
||||
childOrderColumn: "",
|
||||
childOrderDirection: "ASC",
|
||||
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||
loadingMessage: "로딩 중...",
|
||||
clearOnParentChange: true,
|
||||
});
|
||||
|
||||
// 고급 설정 토글
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [parentTableComboOpen, setParentTableComboOpen] = useState(false);
|
||||
const [childTableComboOpen, setChildTableComboOpen] = useState(false);
|
||||
|
||||
// 목록 조회
|
||||
const loadRelations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await cascadingRelationApi.getList("Y");
|
||||
if (response.success && response.data) {
|
||||
setRelations(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("연쇄 관계 목록 조회에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 목록 조회
|
||||
const loadTableList = useCallback(async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setTableList(
|
||||
response.data.map((t: any) => ({
|
||||
tableName: t.tableName || t.name,
|
||||
tableLabel: t.tableLabel || t.displayName || t.tableName || t.name,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 컬럼 목록 조회 (수정됨)
|
||||
const loadColumns = useCallback(async (tableName: string, type: "parent" | "child") => {
|
||||
if (!tableName) return;
|
||||
|
||||
if (type === "parent") {
|
||||
setLoadingParentColumns(true);
|
||||
setParentColumns([]);
|
||||
} else {
|
||||
setLoadingChildColumns(true);
|
||||
setChildColumns([]);
|
||||
}
|
||||
|
||||
try {
|
||||
// getColumnList 사용 (getTableColumns가 아님)
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
console.log(`컬럼 목록 조회 (${tableName}):`, response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 응답 구조: { data: { columns: [...] } }
|
||||
const columnList = response.data.columns || response.data;
|
||||
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||
columnName: c.columnName || c.name,
|
||||
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||
}));
|
||||
|
||||
if (type === "parent") {
|
||||
setParentColumns(columns);
|
||||
// 자동 추천: id, code, _id, _code로 끝나는 컬럼
|
||||
autoSelectColumn(columns, "parentValueColumn", ["id", "code", "_id", "_code"]);
|
||||
} else {
|
||||
setChildColumns(columns);
|
||||
// 자동 추천
|
||||
autoSelectColumn(columns, "childValueColumn", ["id", "code", "_id", "_code"]);
|
||||
autoSelectColumn(columns, "childLabelColumn", ["name", "label", "_name", "description"]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 조회 실패:", error);
|
||||
toast.error(`${tableName} 테이블의 컬럼을 불러오지 못했습니다.`);
|
||||
} finally {
|
||||
if (type === "parent") {
|
||||
setLoadingParentColumns(false);
|
||||
} else {
|
||||
setLoadingChildColumns(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 수정 모드용 컬럼 로드 (자동 선택 없음)
|
||||
const loadColumnsForEdit = async (tableName: string, type: "parent" | "child") => {
|
||||
if (!tableName) return;
|
||||
|
||||
if (type === "parent") {
|
||||
setLoadingParentColumns(true);
|
||||
} else {
|
||||
setLoadingChildColumns(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const columnList = response.data.columns || response.data;
|
||||
|
||||
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||||
columnName: c.columnName || c.name,
|
||||
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||||
}));
|
||||
|
||||
if (type === "parent") {
|
||||
setParentColumns(columns);
|
||||
} else {
|
||||
setChildColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 조회 실패:", error);
|
||||
} finally {
|
||||
if (type === "parent") {
|
||||
setLoadingParentColumns(false);
|
||||
} else {
|
||||
setLoadingChildColumns(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 자동 컬럼 선택 (패턴 매칭)
|
||||
const autoSelectColumn = (columns: ColumnInfo[], field: keyof CascadingRelationCreateInput, patterns: string[]) => {
|
||||
// 이미 값이 있으면 스킵
|
||||
if (formData[field]) return;
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const found = columns.find((c) => c.columnName.toLowerCase().endsWith(pattern.toLowerCase()));
|
||||
if (found) {
|
||||
setFormData((prev) => ({ ...prev, [field]: found.columnName }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRelations();
|
||||
loadTableList();
|
||||
}, [loadRelations, loadTableList]);
|
||||
|
||||
// 부모 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||
useEffect(() => {
|
||||
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||
if (editingRelation) return;
|
||||
|
||||
if (formData.parentTable) {
|
||||
loadColumns(formData.parentTable, "parent");
|
||||
} else {
|
||||
setParentColumns([]);
|
||||
}
|
||||
}, [formData.parentTable, editingRelation]);
|
||||
|
||||
// 자식 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||||
useEffect(() => {
|
||||
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||||
if (editingRelation) return;
|
||||
|
||||
if (formData.childTable) {
|
||||
loadColumns(formData.childTable, "child");
|
||||
} else {
|
||||
setChildColumns([]);
|
||||
}
|
||||
}, [formData.childTable, editingRelation]);
|
||||
|
||||
// 관계 코드 자동 생성
|
||||
const generateRelationCode = (parentTable: string, childTable: string) => {
|
||||
if (!parentTable || !childTable) return "";
|
||||
const parent = parentTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||
const child = childTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||||
return `${parent}_${child}`;
|
||||
};
|
||||
|
||||
// 관계명 자동 생성
|
||||
const generateRelationName = (parentTable: string, childTable: string) => {
|
||||
if (!parentTable || !childTable) return "";
|
||||
const parentInfo = tableList.find((t) => t.tableName === parentTable);
|
||||
const childInfo = tableList.find((t) => t.tableName === childTable);
|
||||
const parentName = parentInfo?.tableLabel || parentTable;
|
||||
const childName = childInfo?.tableLabel || childTable;
|
||||
return `${parentName}-${childName}`;
|
||||
};
|
||||
|
||||
// 모달 열기 (신규)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingRelation(null);
|
||||
setFormData({
|
||||
relationCode: "",
|
||||
relationName: "",
|
||||
description: "",
|
||||
parentTable: "",
|
||||
parentValueColumn: "",
|
||||
parentLabelColumn: "",
|
||||
childTable: "",
|
||||
childFilterColumn: "",
|
||||
childValueColumn: "",
|
||||
childLabelColumn: "",
|
||||
childOrderColumn: "",
|
||||
childOrderDirection: "ASC",
|
||||
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||||
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||||
loadingMessage: "로딩 중...",
|
||||
clearOnParentChange: true,
|
||||
});
|
||||
setParentColumns([]);
|
||||
setChildColumns([]);
|
||||
setShowAdvanced(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = async (relation: CascadingRelation) => {
|
||||
setEditingRelation(relation);
|
||||
setShowAdvanced(false);
|
||||
|
||||
// 먼저 컬럼 목록을 로드 (모달 열기 전)
|
||||
const loadPromises: Promise<void>[] = [];
|
||||
if (relation.parent_table) {
|
||||
loadPromises.push(loadColumnsForEdit(relation.parent_table, "parent"));
|
||||
}
|
||||
if (relation.child_table) {
|
||||
loadPromises.push(loadColumnsForEdit(relation.child_table, "child"));
|
||||
}
|
||||
|
||||
// 컬럼 로드 완료 대기
|
||||
await Promise.all(loadPromises);
|
||||
|
||||
// 컬럼 로드 후 formData 설정 (이렇게 해야 Select에서 값이 제대로 표시됨)
|
||||
setFormData({
|
||||
relationCode: relation.relation_code,
|
||||
relationName: relation.relation_name,
|
||||
description: relation.description || "",
|
||||
parentTable: relation.parent_table,
|
||||
parentValueColumn: relation.parent_value_column,
|
||||
parentLabelColumn: relation.parent_label_column || "",
|
||||
childTable: relation.child_table,
|
||||
childFilterColumn: relation.child_filter_column,
|
||||
childValueColumn: relation.child_value_column,
|
||||
childLabelColumn: relation.child_label_column,
|
||||
childOrderColumn: relation.child_order_column || "",
|
||||
childOrderDirection: relation.child_order_direction || "ASC",
|
||||
emptyParentMessage: relation.empty_parent_message || "상위 항목을 먼저 선택하세요",
|
||||
noOptionsMessage: relation.no_options_message || "선택 가능한 항목이 없습니다",
|
||||
loadingMessage: relation.loading_message || "로딩 중...",
|
||||
clearOnParentChange: relation.clear_on_parent_change === "Y",
|
||||
});
|
||||
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 부모 테이블 선택 시 자동 설정
|
||||
const handleParentTableChange = async (value: string) => {
|
||||
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||
const shouldClearColumns = value !== formData.parentTable;
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
parentTable: value,
|
||||
parentValueColumn: shouldClearColumns ? "" : prev.parentValueColumn,
|
||||
parentLabelColumn: shouldClearColumns ? "" : prev.parentLabelColumn,
|
||||
}));
|
||||
|
||||
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||
if (editingRelation && value) {
|
||||
await loadColumnsForEdit(value, "parent");
|
||||
}
|
||||
};
|
||||
|
||||
// 자식 테이블 선택 시 자동 설정
|
||||
const handleChildTableChange = async (value: string) => {
|
||||
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||||
const shouldClearColumns = value !== formData.childTable;
|
||||
|
||||
const newFormData = {
|
||||
...formData,
|
||||
childTable: value,
|
||||
childFilterColumn: shouldClearColumns ? "" : formData.childFilterColumn,
|
||||
childValueColumn: shouldClearColumns ? "" : formData.childValueColumn,
|
||||
childLabelColumn: shouldClearColumns ? "" : formData.childLabelColumn,
|
||||
childOrderColumn: shouldClearColumns ? "" : formData.childOrderColumn,
|
||||
};
|
||||
|
||||
// 관계 코드/이름 자동 생성 (신규 모드에서만)
|
||||
if (!editingRelation) {
|
||||
newFormData.relationCode = generateRelationCode(formData.parentTable, value);
|
||||
newFormData.relationName = generateRelationName(formData.parentTable, value);
|
||||
}
|
||||
|
||||
setFormData(newFormData);
|
||||
|
||||
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||||
if (editingRelation && value) {
|
||||
await loadColumnsForEdit(value, "child");
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 필수 필드 검증
|
||||
if (!formData.parentTable || !formData.parentValueColumn) {
|
||||
toast.error("부모 테이블과 값 컬럼을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!formData.childTable ||
|
||||
!formData.childFilterColumn ||
|
||||
!formData.childValueColumn ||
|
||||
!formData.childLabelColumn
|
||||
) {
|
||||
toast.error("자식 테이블 설정을 완료해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 관계 코드/이름 자동 생성 (비어있으면)
|
||||
const finalData = { ...formData };
|
||||
if (!finalData.relationCode) {
|
||||
finalData.relationCode = generateRelationCode(formData.parentTable, formData.childTable);
|
||||
}
|
||||
if (!finalData.relationName) {
|
||||
finalData.relationName = generateRelationName(formData.parentTable, formData.childTable);
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
let response;
|
||||
if (editingRelation) {
|
||||
response = await cascadingRelationApi.update(editingRelation.relation_id, finalData);
|
||||
} else {
|
||||
response = await cascadingRelationApi.create(finalData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingRelation ? "연쇄 관계가 수정되었습니다." : "연쇄 관계가 생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadRelations();
|
||||
} else {
|
||||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async (relation: CascadingRelation) => {
|
||||
if (!confirm(`"${relation.relation_name}" 관계를 삭제하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await cascadingRelationApi.delete(relation.relation_id);
|
||||
if (response.success) {
|
||||
toast.success("연쇄 관계가 삭제되었습니다.");
|
||||
loadRelations();
|
||||
} else {
|
||||
toast.error(response.message || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 필터링된 목록
|
||||
const filteredRelations = relations.filter(
|
||||
(r) =>
|
||||
r.relation_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.relation_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.parent_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
r.child_table.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
// 컬럼 셀렉트 렌더링 헬퍼
|
||||
const renderColumnSelect = (
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
columns: ColumnInfo[],
|
||||
loading: boolean,
|
||||
placeholder: string,
|
||||
disabled?: boolean,
|
||||
) => (
|
||||
<Select value={value} onValueChange={onChange} disabled={disabled || loading}>
|
||||
<SelectTrigger className="h-9">
|
||||
{loading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="text-xs">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={placeholder} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.length === 0 ? (
|
||||
<div className="text-muted-foreground p-2 text-center text-xs">테이블을 먼저 선택하세요</div>
|
||||
) : (
|
||||
columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.columnLabel}</span>
|
||||
{col.columnLabel !== col.columnName && (
|
||||
<span className="text-muted-foreground text-xs">({col.columnName})</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5" />
|
||||
2단계 연쇄 관계
|
||||
</CardTitle>
|
||||
<CardDescription>부모-자식 관계로 연결된 드롭다운을 정의합니다. (예: 창고 → 위치)</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadRelations} disabled={loading}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button onClick={handleOpenCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 관계 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 검색 */}
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<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={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>관계명</TableHead>
|
||||
<TableHead>연결</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="w-[100px]">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredRelations.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "등록된 연쇄 관계가 없습니다."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRelations.map((relation) => (
|
||||
<TableRow key={relation.relation_id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{relation.relation_name}</div>
|
||||
<div className="text-muted-foreground font-mono text-xs">{relation.relation_code}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">{relation.parent_table}</span>
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||
<span className="rounded bg-green-100 px-2 py-0.5 text-green-700">
|
||||
{relation.child_table}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={relation.is_active === "Y" ? "default" : "secondary"}>
|
||||
{relation.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(relation)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDelete(relation)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 - 간소화된 UI */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingRelation ? "연쇄 관계 수정" : "새 연쇄 관계"}</DialogTitle>
|
||||
<DialogDescription>부모 테이블 선택 시 자식 테이블의 옵션이 필터링됩니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Step 1: 부모 테이블 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-blue-600">1. 부모 (상위 선택)</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">테이블</Label>
|
||||
<Popover open={parentTableComboOpen} onOpenChange={setParentTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={parentTableComboOpen}
|
||||
className="h-9 w-full justify-between text-sm"
|
||||
>
|
||||
{loadingTables
|
||||
? "로딩 중..."
|
||||
: formData.parentTable
|
||||
? tableList.find((t) => t.tableName === formData.parentTable)?.tableLabel ||
|
||||
formData.parentTable
|
||||
: "테이블 선택"}
|
||||
<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>
|
||||
{tableList.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||
onSelect={() => {
|
||||
handleParentTableChange(table.tableName);
|
||||
setParentTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.parentTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">값 컬럼 (필터링 기준)</Label>
|
||||
{renderColumnSelect(
|
||||
formData.parentValueColumn,
|
||||
(v) => setFormData({ ...formData, parentValueColumn: v }),
|
||||
parentColumns,
|
||||
loadingParentColumns,
|
||||
"컬럼 선택",
|
||||
!formData.parentTable,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2: 자식 테이블 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold text-green-600">2. 자식 (하위 옵션)</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">테이블</Label>
|
||||
<Popover open={childTableComboOpen} onOpenChange={setChildTableComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={childTableComboOpen}
|
||||
className="h-9 w-full justify-between text-sm"
|
||||
disabled={!formData.parentTable}
|
||||
>
|
||||
{formData.childTable
|
||||
? tableList.find((t) => t.tableName === formData.childTable)?.tableLabel ||
|
||||
formData.childTable
|
||||
: "테이블 선택"}
|
||||
<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>
|
||||
{tableList.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.tableLabel || ""}`}
|
||||
onSelect={() => {
|
||||
handleChildTableChange(table.tableName);
|
||||
setChildTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.childTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
||||
{table.tableLabel && table.tableLabel !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">필터 컬럼 (부모 값과 매칭)</Label>
|
||||
{renderColumnSelect(
|
||||
formData.childFilterColumn,
|
||||
(v) => setFormData({ ...formData, childFilterColumn: v }),
|
||||
childColumns,
|
||||
loadingChildColumns,
|
||||
"컬럼 선택",
|
||||
!formData.childTable,
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">값 컬럼 (저장될 값)</Label>
|
||||
{renderColumnSelect(
|
||||
formData.childValueColumn,
|
||||
(v) => setFormData({ ...formData, childValueColumn: v }),
|
||||
childColumns,
|
||||
loadingChildColumns,
|
||||
"컬럼 선택",
|
||||
!formData.childTable,
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">라벨 컬럼 (표시될 텍스트)</Label>
|
||||
{renderColumnSelect(
|
||||
formData.childLabelColumn,
|
||||
(v) => setFormData({ ...formData, childLabelColumn: v }),
|
||||
childColumns,
|
||||
loadingChildColumns,
|
||||
"컬럼 선택",
|
||||
!formData.childTable,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 관계 정보 (자동 생성) */}
|
||||
{formData.parentTable && formData.childTable && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">관계 코드</Label>
|
||||
<Input
|
||||
value={formData.relationCode || generateRelationCode(formData.parentTable, formData.childTable)}
|
||||
onChange={(e) => setFormData({ ...formData, relationCode: e.target.value.toUpperCase() })}
|
||||
placeholder="자동 생성"
|
||||
className="h-8 text-xs"
|
||||
disabled={!!editingRelation}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">관계명</Label>
|
||||
<Input
|
||||
value={formData.relationName || generateRelationName(formData.parentTable, formData.childTable)}
|
||||
onChange={(e) => setFormData({ ...formData, relationName: e.target.value })}
|
||||
placeholder="자동 생성"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 고급 설정 토글 */}
|
||||
<div className="border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between text-xs"
|
||||
>
|
||||
<span>고급 설정</span>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">설명</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="이 관계에 대한 설명..."
|
||||
rows={2}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">상위 미선택 메시지</Label>
|
||||
<Input
|
||||
value={formData.emptyParentMessage}
|
||||
onChange={(e) => setFormData({ ...formData, emptyParentMessage: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">옵션 없음 메시지</Label>
|
||||
<Input
|
||||
value={formData.noOptionsMessage}
|
||||
onChange={(e) => setFormData({ ...formData, noOptionsMessage: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">부모 변경 시 초기화</Label>
|
||||
<p className="text-muted-foreground text-xs">부모 값 변경 시 자식 선택 초기화</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.clearOnParentChange}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, clearOnParentChange: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : editingRelation ? (
|
||||
"수정"
|
||||
) : (
|
||||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
"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 { Switch } from "@/components/ui/switch";
|
||||
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 { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
cascadingConditionApi,
|
||||
CascadingCondition,
|
||||
CONDITION_OPERATORS,
|
||||
} from "@/lib/api/cascadingCondition";
|
||||
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||
|
||||
export default function ConditionTab() {
|
||||
// 목록 상태
|
||||
const [conditions, setConditions] = useState<CascadingCondition[]>([]);
|
||||
const [relations, setRelations] = useState<Array<{ relation_code: string; relation_name: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingCondition, setEditingCondition] = useState<CascadingCondition | null>(null);
|
||||
const [deletingConditionId, setDeletingConditionId] = useState<number | null>(null);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<Omit<CascadingCondition, "conditionId">>({
|
||||
relationType: "RELATION",
|
||||
relationCode: "",
|
||||
conditionName: "",
|
||||
conditionField: "",
|
||||
conditionOperator: "EQ",
|
||||
conditionValue: "",
|
||||
filterColumn: "",
|
||||
filterValues: "",
|
||||
priority: 0,
|
||||
});
|
||||
|
||||
// 목록 로드
|
||||
const loadConditions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await cascadingConditionApi.getList();
|
||||
if (response.success && response.data) {
|
||||
setConditions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("조건 목록 로드 실패:", error);
|
||||
toast.error("조건 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 연쇄 관계 목록 로드
|
||||
const loadRelations = useCallback(async () => {
|
||||
try {
|
||||
const response = await cascadingRelationApi.getList("Y");
|
||||
if (response.success && response.data) {
|
||||
setRelations(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("연쇄 관계 목록 로드 실패:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConditions();
|
||||
loadRelations();
|
||||
}, [loadConditions, loadRelations]);
|
||||
|
||||
// 필터된 목록
|
||||
const filteredConditions = conditions.filter(
|
||||
(c) =>
|
||||
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
c.conditionField?.toLowerCase().includes(searchText.toLowerCase())
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingCondition(null);
|
||||
setFormData({
|
||||
relationType: "RELATION",
|
||||
relationCode: "",
|
||||
conditionName: "",
|
||||
conditionField: "",
|
||||
conditionOperator: "EQ",
|
||||
conditionValue: "",
|
||||
filterColumn: "",
|
||||
filterValues: "",
|
||||
priority: 0,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = (condition: CascadingCondition) => {
|
||||
setEditingCondition(condition);
|
||||
setFormData({
|
||||
relationType: condition.relationType || "RELATION",
|
||||
relationCode: condition.relationCode,
|
||||
conditionName: condition.conditionName,
|
||||
conditionField: condition.conditionField,
|
||||
conditionOperator: condition.conditionOperator,
|
||||
conditionValue: condition.conditionValue,
|
||||
filterColumn: condition.filterColumn,
|
||||
filterValues: condition.filterValues,
|
||||
priority: condition.priority || 0,
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (conditionId: number) => {
|
||||
setDeletingConditionId(conditionId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingConditionId) return;
|
||||
|
||||
try {
|
||||
const response = await cascadingConditionApi.delete(deletingConditionId);
|
||||
if (response.success) {
|
||||
toast.success("조건부 규칙이 삭제되었습니다.");
|
||||
loadConditions();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingConditionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.relationCode || !formData.conditionName || !formData.conditionField) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.conditionValue || !formData.filterColumn || !formData.filterValues) {
|
||||
toast.error("조건 값, 필터 컬럼, 필터 값을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingCondition) {
|
||||
response = await cascadingConditionApi.update(editingCondition.conditionId!, formData);
|
||||
} else {
|
||||
response = await cascadingConditionApi.create(formData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingCondition ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadConditions();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 연산자 라벨 찾기
|
||||
const getOperatorLabel = (operator: string) => {
|
||||
return CONDITION_OPERATORS.find((op) => op.value === operator)?.label || operator;
|
||||
};
|
||||
|
||||
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 left-3 top-1/2 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={loadConditions}>
|
||||
<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">
|
||||
<Filter className="h-5 w-5" />
|
||||
조건부 필터 규칙
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
특정 필드 값에 따라 드롭다운 옵션을 필터링합니다. (총 {filteredConditions.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>
|
||||
) : filteredConditions.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>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>연쇄 관계</TableHead>
|
||||
<TableHead>조건명</TableHead>
|
||||
<TableHead>조건</TableHead>
|
||||
<TableHead>필터</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredConditions.map((condition) => (
|
||||
<TableRow key={condition.conditionId}>
|
||||
<TableCell className="font-mono text-sm">{condition.relationCode}</TableCell>
|
||||
<TableCell className="font-medium">{condition.conditionName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{condition.conditionField}</span>
|
||||
<span className="mx-1 text-blue-600">{getOperatorLabel(condition.conditionOperator)}</span>
|
||||
<span className="font-medium">{condition.conditionValue}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">{condition.filterColumn}</span>
|
||||
<span className="mx-1">=</span>
|
||||
<span className="font-mono text-xs">{condition.filterValues}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={condition.isActive === "Y" ? "default" : "secondary"}>
|
||||
{condition.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteConfirm(condition.conditionId!)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
특정 필드 값에 따라 드롭다운 옵션을 필터링하는 규칙을 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 연쇄 관계 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label>연쇄 관계 *</Label>
|
||||
<Select
|
||||
value={formData.relationCode}
|
||||
onValueChange={(value) => setFormData({ ...formData, relationCode: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="연쇄 관계 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{relations.map((rel) => (
|
||||
<SelectItem key={rel.relation_code} value={rel.relation_code}>
|
||||
{rel.relation_name} ({rel.relation_code})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조건명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>조건명 *</Label>
|
||||
<Input
|
||||
value={formData.conditionName}
|
||||
onChange={(e) => setFormData({ ...formData, conditionName: e.target.value })}
|
||||
placeholder="예: 활성 품목만 표시"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조건 설정 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold">조건 설정</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">조건 필드 *</Label>
|
||||
<Input
|
||||
value={formData.conditionField}
|
||||
onChange={(e) => setFormData({ ...formData, conditionField: e.target.value })}
|
||||
placeholder="예: status"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">연산자 *</Label>
|
||||
<Select
|
||||
value={formData.conditionOperator}
|
||||
onValueChange={(value) => setFormData({ ...formData, conditionOperator: value })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONDITION_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">조건 값 *</Label>
|
||||
<Input
|
||||
value={formData.conditionValue}
|
||||
onChange={(e) => setFormData({ ...formData, conditionValue: e.target.value })}
|
||||
placeholder="예: active"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
폼의 "{formData.conditionField || "필드"}" 값이 "{formData.conditionValue || "값"}"일 때 필터 적용
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필터 설정 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="mb-3 text-sm font-semibold">필터 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">필터 컬럼 *</Label>
|
||||
<Input
|
||||
value={formData.filterColumn}
|
||||
onChange={(e) => setFormData({ ...formData, filterColumn: e.target.value })}
|
||||
placeholder="예: status"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">필터 값 *</Label>
|
||||
<Input
|
||||
value={formData.filterValues}
|
||||
onChange={(e) => setFormData({ ...formData, filterValues: e.target.value })}
|
||||
placeholder="예: active,pending"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
드롭다운 옵션 중 "{formData.filterColumn || "컬럼"}"이 "{formData.filterValues || "값"}"인 항목만 표시
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 우선순위 */}
|
||||
<div className="space-y-2">
|
||||
<Label>우선순위</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number(e.target.value) })}
|
||||
placeholder="높을수록 먼저 적용"
|
||||
className="w-32"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
여러 조건이 일치할 경우 우선순위가 높은 규칙이 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingCondition ? "수정" : "생성"}</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
"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 { 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 { Ban, Check, ChevronsUpDown, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mutualExclusionApi, MutualExclusion, EXCLUSION_TYPES } from "@/lib/api/cascadingMutualExclusion";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
export default function MutualExclusionTab() {
|
||||
// 목록 상태
|
||||
const [exclusions, setExclusions] = useState<MutualExclusion[]>([]);
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [columns, setColumns] = useState<Array<{ columnName: string; displayName: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
// 테이블 Combobox 상태
|
||||
const [tableComboOpen, setTableComboOpen] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [editingExclusion, setEditingExclusion] = useState<MutualExclusion | null>(null);
|
||||
const [deletingExclusionId, setDeletingExclusionId] = useState<number | null>(null);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<Omit<MutualExclusion, "exclusionId" | "exclusionCode">>({
|
||||
exclusionName: "",
|
||||
fieldNames: "",
|
||||
sourceTable: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
exclusionType: "SAME_VALUE",
|
||||
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||
});
|
||||
|
||||
// 필드 목록 (동적 추가)
|
||||
const [fieldList, setFieldList] = useState<string[]>(["", ""]);
|
||||
|
||||
// 목록 로드
|
||||
const loadExclusions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await mutualExclusionApi.getList();
|
||||
if (response.success && response.data) {
|
||||
setExclusions(response.data);
|
||||
}
|
||||
} 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(() => {
|
||||
loadExclusions();
|
||||
loadTables();
|
||||
}, [loadExclusions, loadTables]);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
const loadColumns = async (tableName: string) => {
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data?.columns) {
|
||||
setColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 필터된 목록
|
||||
const filteredExclusions = exclusions.filter(
|
||||
(e) =>
|
||||
e.exclusionName?.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
e.exclusionCode?.toLowerCase().includes(searchText.toLowerCase()),
|
||||
);
|
||||
|
||||
// 모달 열기 (생성)
|
||||
const handleOpenCreate = () => {
|
||||
setEditingExclusion(null);
|
||||
setFormData({
|
||||
exclusionName: "",
|
||||
fieldNames: "",
|
||||
sourceTable: "",
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
exclusionType: "SAME_VALUE",
|
||||
errorMessage: "동일한 값을 선택할 수 없습니다",
|
||||
});
|
||||
setFieldList(["", ""]);
|
||||
setColumns([]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 모달 열기 (수정)
|
||||
const handleOpenEdit = async (exclusion: MutualExclusion) => {
|
||||
setEditingExclusion(exclusion);
|
||||
setFormData({
|
||||
exclusionCode: exclusion.exclusionCode,
|
||||
exclusionName: exclusion.exclusionName,
|
||||
fieldNames: exclusion.fieldNames,
|
||||
sourceTable: exclusion.sourceTable,
|
||||
valueColumn: exclusion.valueColumn,
|
||||
labelColumn: exclusion.labelColumn || "",
|
||||
exclusionType: exclusion.exclusionType || "SAME_VALUE",
|
||||
errorMessage: exclusion.errorMessage || "동일한 값을 선택할 수 없습니다",
|
||||
});
|
||||
setFieldList(exclusion.fieldNames.split(",").map((f) => f.trim()));
|
||||
await loadColumns(exclusion.sourceTable);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteConfirm = (exclusionId: number) => {
|
||||
setDeletingExclusionId(exclusionId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDelete = async () => {
|
||||
if (!deletingExclusionId) return;
|
||||
|
||||
try {
|
||||
const response = await mutualExclusionApi.delete(deletingExclusionId);
|
||||
if (response.success) {
|
||||
toast.success("상호 배제 규칙이 삭제되었습니다.");
|
||||
loadExclusions();
|
||||
} else {
|
||||
toast.error(response.error || "삭제에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("삭제 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeletingExclusionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 추가
|
||||
const addField = () => {
|
||||
setFieldList([...fieldList, ""]);
|
||||
};
|
||||
|
||||
// 필드 제거
|
||||
const removeField = (index: number) => {
|
||||
if (fieldList.length <= 2) {
|
||||
toast.error("최소 2개의 필드가 필요합니다.");
|
||||
return;
|
||||
}
|
||||
setFieldList(fieldList.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 필드 값 변경
|
||||
const updateField = (index: number, value: string) => {
|
||||
const newFields = [...fieldList];
|
||||
newFields[index] = value;
|
||||
setFieldList(newFields);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
// 필드 목록 합치기
|
||||
const cleanedFields = fieldList.filter((f) => f.trim());
|
||||
if (cleanedFields.length < 2) {
|
||||
toast.error("최소 2개의 필드를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 유효성 검사
|
||||
if (!formData.exclusionName || !formData.sourceTable || !formData.valueColumn) {
|
||||
toast.error("필수 항목을 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToSave = {
|
||||
...formData,
|
||||
fieldNames: cleanedFields.join(","),
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingExclusion) {
|
||||
response = await mutualExclusionApi.update(editingExclusion.exclusionId!, dataToSave);
|
||||
} else {
|
||||
response = await mutualExclusionApi.create(dataToSave);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
toast.success(editingExclusion ? "수정되었습니다." : "생성되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
loadExclusions();
|
||||
} else {
|
||||
toast.error(response.error || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 선택 핸들러
|
||||
const handleTableChange = async (tableName: string) => {
|
||||
setFormData({ ...formData, sourceTable: tableName, valueColumn: "", labelColumn: "" });
|
||||
await loadColumns(tableName);
|
||||
};
|
||||
|
||||
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={loadExclusions}>
|
||||
<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">
|
||||
<Ban className="h-5 w-5" />
|
||||
상호 배제 규칙
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
두 필드가 같은 값을 선택할 수 없도록 제한합니다. (총 {filteredExclusions.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>
|
||||
) : filteredExclusions.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>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>배제 코드</TableHead>
|
||||
<TableHead>배제명</TableHead>
|
||||
<TableHead>대상 필드</TableHead>
|
||||
<TableHead>소스 테이블</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredExclusions.map((exclusion) => (
|
||||
<TableRow key={exclusion.exclusionId}>
|
||||
<TableCell className="font-mono text-sm">{exclusion.exclusionCode}</TableCell>
|
||||
<TableCell className="font-medium">{exclusion.exclusionName}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{exclusion.fieldNames.split(",").map((field, idx) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs">
|
||||
{field.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{exclusion.sourceTable}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={exclusion.isActive === "Y" ? "default" : "secondary"}>
|
||||
{exclusion.isActive === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(exclusion)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(exclusion.exclusionId!)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 생성/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingExclusion ? "상호 배제 규칙 수정" : "상호 배제 규칙 생성"}</DialogTitle>
|
||||
<DialogDescription>두 필드가 같은 값을 선택할 수 없도록 제한합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 배제명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>배제명 *</Label>
|
||||
<Input
|
||||
value={formData.exclusionName}
|
||||
onChange={(e) => setFormData({ ...formData, exclusionName: e.target.value })}
|
||||
placeholder="예: 창고 이동 제한"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 대상 필드 */}
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">대상 필드 (최소 2개)</h4>
|
||||
<Button variant="outline" size="sm" onClick={addField}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{fieldList.map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={field}
|
||||
onChange={(e) => updateField(index, e.target.value)}
|
||||
placeholder={`필드 ${index + 1} (예: source_warehouse)`}
|
||||
className="flex-1"
|
||||
/>
|
||||
{fieldList.length > 2 && (
|
||||
<Button variant="ghost" size="icon" onClick={() => removeField(index)}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">이 필드들은 서로 같은 값을 선택할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 소스 테이블 및 컬럼 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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"
|
||||
>
|
||||
{formData.sourceTable
|
||||
? tables.find((t) => t.tableName === formData.sourceTable)?.displayName || formData.sourceTable
|
||||
: "테이블 선택"}
|
||||
<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
|
||||
.filter((t) => t.tableName)
|
||||
.map((t) => (
|
||||
<CommandItem
|
||||
key={t.tableName}
|
||||
value={`${t.tableName} ${t.displayName || ""}`}
|
||||
onSelect={async () => {
|
||||
setFormData({
|
||||
...formData,
|
||||
sourceTable: t.tableName,
|
||||
valueColumn: "",
|
||||
labelColumn: "",
|
||||
});
|
||||
await loadColumns(t.tableName);
|
||||
setTableComboOpen(false);
|
||||
}}
|
||||
className="text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.sourceTable === 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="space-y-2">
|
||||
<Label>값 컬럼 *</Label>
|
||||
<Select
|
||||
value={formData.valueColumn}
|
||||
onValueChange={(v) => setFormData({ ...formData, valueColumn: v })}
|
||||
disabled={!formData.sourceTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="값 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter((c) => c.columnName)
|
||||
.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName}>
|
||||
{c.displayName || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>라벨 컬럼</Label>
|
||||
<Select
|
||||
value={formData.labelColumn}
|
||||
onValueChange={(v) => setFormData({ ...formData, labelColumn: v })}
|
||||
disabled={!formData.sourceTable}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="라벨 컬럼 선택 (선택)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns
|
||||
.filter((c) => c.columnName)
|
||||
.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={formData.exclusionType}
|
||||
onValueChange={(v) => setFormData({ ...formData, exclusionType: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXCLUSION_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
<div className="space-y-2">
|
||||
<Label>에러 메시지</Label>
|
||||
<Input
|
||||
value={formData.errorMessage}
|
||||
onChange={(e) => setFormData({ ...formData, errorMessage: e.target.value })}
|
||||
placeholder="동일한 값을 선택할 수 없습니다"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>{editingExclusion ? "수정" : "생성"}</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user