502 lines
19 KiB
TypeScript
502 lines
19 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 { 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>
|
|
);
|
|
}
|