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

583 lines
22 KiB
TypeScript
Raw Normal View History

2025-12-10 15:59:04 +09:00
"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>
);
}