583 lines
22 KiB
TypeScript
583 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { 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>
|
|
);
|
|
}
|