- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
503 lines
19 KiB
TypeScript
503 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 { showErrorToast } from "@/lib/utils/toastUtils";
|
|
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) {
|
|
showErrorToast("조건 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
} 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) {
|
|
showErrorToast("조건 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
};
|
|
|
|
// 연산자 라벨 찾기
|
|
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>
|
|
);
|
|
}
|