diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-19 09:44:28 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-19 09:44:28 +0000 |
| commit | 95bbe9c583ff841220da1267630e7b2025fc36dc (patch) | |
| tree | 5e3d5bb3302530bbaa7f7abbe8c9cf8193ccbd4c /lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx | |
| parent | 0eb030580b5cbe5f03d570c3c9d8c519bac3b783 (diff) | |
(대표님) 20250619 1844 KST 작업사항
Diffstat (limited to 'lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx')
| -rw-r--r-- | lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx | 772 |
1 files changed, 772 insertions, 0 deletions
diff --git a/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx new file mode 100644 index 00000000..5704cba1 --- /dev/null +++ b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx @@ -0,0 +1,772 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Check, ChevronsUpDown, Loader2, Search } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +import { + createEvaluationTarget, + getAvailableVendors, + getAvailableReviewers, + getDepartmentInfo, + type CreateEvaluationTargetInput, +} from "../service" +import { EVALUATION_TARGET_FILTER_OPTIONS, getDefaultEvaluationYear } from "../validation" +import { useSession } from "next-auth/react" + +// 폼 스키마 정의 +const createEvaluationTargetSchema = z.object({ + evaluationYear: z.number().min(2020).max(2030), + division: z.enum(["OCEAN", "SHIPYARD"]), + vendorId: z.number().min(1, "벤더를 선택해주세요"), + materialType: z.enum(["EQUIPMENT", "BULK", "EQUIPMENT_BULK"]), + adminComment: z.string().optional(), + // L/D 클레임 정보 + ldClaimCount: z.number().min(0).optional(), + ldClaimAmount: z.number().min(0).optional(), + ldClaimCurrency: z.enum(["KRW", "USD", "EUR", "JPY"]).optional(), + reviewers: z.array( + z.object({ + departmentCode: z.string(), + reviewerUserId: z.number().min(1, "담당자를 선택해주세요"), + }) + ).min(1, "최소 1명의 담당자를 지정해주세요"), +}) + +type CreateEvaluationTargetFormValues = z.infer<typeof createEvaluationTargetSchema> + +interface ManualCreateEvaluationTargetDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function ManualCreateEvaluationTargetDialog({ + open, + onOpenChange, +}: ManualCreateEvaluationTargetDialogProps) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = React.useState(false) + const { data: session } = useSession() + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : 1; + }, [session]); + + // 벤더 관련 상태 + const [vendors, setVendors] = React.useState<Array<{ id: number, vendorCode: string, vendorName: string }>>([]) + const [vendorSearch, setVendorSearch] = React.useState("") + const [vendorOpen, setVendorOpen] = React.useState(false) + const [isLoadingVendors, setIsLoadingVendors] = React.useState(false) + + // 담당자 관련 상태 + const [reviewers, setReviewers] = React.useState<Array<{ id: number, name: string, email: string }>>([]) + const [isLoadingReviewers, setIsLoadingReviewers] = React.useState(false) + + // 각 부서별 담당자 선택 상태 + const [reviewerSearches, setReviewerSearches] = React.useState<Record<string, string>>({}) + const [reviewerOpens, setReviewerOpens] = React.useState<Record<string, boolean>>({}) + + // 부서 정보 상태 + const [departments, setDepartments] = React.useState<Array<{ code: string, name: string, key: string }>>([]) + + const form = useForm<CreateEvaluationTargetFormValues>({ + resolver: zodResolver(createEvaluationTargetSchema), + defaultValues: { + evaluationYear: getDefaultEvaluationYear(), + division: "OCEAN", + vendorId: 0, + materialType: "EQUIPMENT", + adminComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + reviewers: [], // 초기에는 빈 배열, useEffect에서 설정 + }, + }) + + // 부서 정보 로드 + const loadDepartments = React.useCallback(async () => { + try { + const departmentList = await getDepartmentInfo() + setDepartments(departmentList) + } catch (error) { + console.error("Error loading departments:", error) + toast.error("부서 정보를 불러오는데 실패했습니다.") + } + }, []) // form 의존성 제거 + + // 벤더 목록 로드 + const loadVendors = React.useCallback(async (search?: string) => { + setIsLoadingVendors(true) + try { + const vendorList = await getAvailableVendors(search) + setVendors(vendorList) + } catch (error) { + console.error("Error loading vendors:", error) + toast.error("벤더 목록을 불러오는데 실패했습니다.") + } finally { + setIsLoadingVendors(false) + } + }, []) + + // 담당자 목록 로드 + const loadReviewers = React.useCallback(async () => { + setIsLoadingReviewers(true) + try { + const reviewerList = await getAvailableReviewers() + setReviewers(reviewerList) + } catch (error) { + console.error("Error loading reviewers:", error) + toast.error("담당자 목록을 불러오는데 실패했습니다.") + } finally { + setIsLoadingReviewers(false) + } + }, []) + + // 다이얼로그 열릴 때 데이터 로드 + React.useEffect(() => { + if (open) { + loadDepartments() + loadVendors() + loadReviewers() + } + }, [open]) // 함수 의존성 제거 + + // 부서 정보가 로드되면 reviewers 기본값 설정 + React.useEffect(() => { + if (departments.length > 0 && open) { + const currentReviewers = form.getValues("reviewers") + + // 이미 설정되어 있으면 다시 설정하지 않음 + if (currentReviewers.length === 0) { + const defaultReviewers = departments.map(dept => ({ + departmentCode: dept.code, + reviewerUserId: 0, + })) + form.setValue('reviewers', defaultReviewers) + } + } + }, [departments, open]) // form 의존성 제거하고 조건 추가 + + console.log(departments) + + // 벤더 검색 + React.useEffect(() => { + const timeoutId = setTimeout(() => { + if (vendorSearch || vendorOpen) { + loadVendors(vendorSearch) + } + }, 300) + + return () => clearTimeout(timeoutId) + }, [vendorSearch, vendorOpen]) // loadVendors 의존성 제거 + + // 폼 제출 + async function onSubmit(data: CreateEvaluationTargetFormValues) { + console.log("Form submitted with data:", data) // 디버깅용 + setIsSubmitting(true) + try { + // 담당자가 지정되지 않은 부서 제외 + const validReviewers = data.reviewers.filter(r => r.reviewerUserId > 0) + + if (validReviewers.length === 0) { + toast.error("최소 1명의 담당자를 지정해주세요.") + return + } + + const input: CreateEvaluationTargetInput = { + ...data, + reviewers: validReviewers, + } + + console.log(input,"client") + + const result = await createEvaluationTarget(input, userId) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + // 폼과 상태 초기화 + form.reset({ + evaluationYear: getDefaultEvaluationYear(), + division: "OCEAN", + vendorId: 0, + materialType: "EQUIPMENT", + adminComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + reviewers: [], + }) + setDepartments([]) + setVendors([]) + setReviewers([]) + setVendorSearch("") + setReviewerSearches({}) + setReviewerOpens({}) + router.refresh() + } else { + toast.error(result.error || "평가 대상 생성에 실패했습니다.") + } + } catch (error) { + console.error("Error creating evaluation target:", error) + toast.error("평가 대상 생성 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 다이얼로그 닫기 핸들러 + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + + // 다이얼로그가 닫힐 때 상태 초기화 + if (!open) { + form.reset({ + evaluationYear: getDefaultEvaluationYear(), + division: "OCEAN", + vendorId: 0, + materialType: "EQUIPMENT", + adminComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + reviewers: [], + }) + setDepartments([]) + setVendors([]) + setReviewers([]) + setVendorSearch("") + setReviewerSearches({}) + setReviewerOpens({}) + } + } + + // 선택된 벤더 정보 + const selectedVendor = vendors.find(v => v.id === form.watch("vendorId")) + + // 현재 선택된 자재구분 + const currentMaterialType = form.watch("materialType") + + // BULK 자재일 때 비활성화할 부서 코드들 + const BULK_DISABLED_DEPARTMENTS = ["DESIGN_EVAL", "CS_EVAL"] // 설계(DES), CS 부서 코드 (실제 코드에 맞게 수정 필요) + + // 담당자 검색 필터링 + const getFilteredReviewers = (search: string) => { + if (!search) return reviewers + return reviewers.filter(reviewer => + reviewer.name.toLowerCase().includes(search.toLowerCase()) || + reviewer.email.toLowerCase().includes(search.toLowerCase()) + ) + } + + // 부서가 비활성화되어야 하는지 확인 + const isDepartmentDisabled = (departmentCode: string) => { + return currentMaterialType === "BULK" && BULK_DISABLED_DEPARTMENTS.includes(departmentCode) + } + + // 자재구분 변경 시 BULK 비활성화 부서들의 담당자 초기화 + React.useEffect(() => { + if (currentMaterialType === "BULK") { + const currentReviewers = form.getValues("reviewers") + const updatedReviewers = currentReviewers.map(reviewer => { + if (BULK_DISABLED_DEPARTMENTS.includes(reviewer.departmentCode)) { + return { ...reviewer, reviewerUserId: 0 } + } + return reviewer + }) + form.setValue("reviewers", updatedReviewers) + } + }, [currentMaterialType, form]) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-lg flex flex-col h-[90vh]"> + {/* 고정 헤더 */} + <DialogHeader className="flex-shrink-0"> + <DialogTitle>평가 대상 수동 생성</DialogTitle> + <DialogDescription> + 새로운 평가 대상을 수동으로 생성하고 담당자를 지정합니다. + </DialogDescription> + </DialogHeader> + + {/* Form을 전체 콘텐츠를 감싸도록 수정 */} + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col flex-1" + id="evaluation-target-form" + > + {/* 스크롤 가능한 콘텐츠 영역 */} + <div className="flex-1 overflow-y-auto"> + <div className="space-y-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + {/* 평가년도 */} + <FormField + control={form.control} + name="evaluationYear" + render={({ field }) => ( + <FormItem> + <FormLabel>평가년도</FormLabel> + <FormControl> + <Input + type="number" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value))} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구분 */} + <FormField + control={form.control} + name="division" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {EVALUATION_TARGET_FILTER_OPTIONS.DIVISIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 벤더 선택 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem> + <FormLabel>벤더</FormLabel> + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + > + {selectedVendor ? ( + <span className="flex items-center gap-2"> + <Badge variant="outline">{selectedVendor.vendorCode}</Badge> + {selectedVendor.vendorName} + </span> + ) : ( + "벤더 선택..." + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput + placeholder="벤더 검색..." + value={vendorSearch} + onValueChange={setVendorSearch} + /> + <CommandList> + <CommandEmpty> + {isLoadingVendors ? ( + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + 로딩 중... + </div> + ) : ( + "검색 결과가 없습니다." + )} + </CommandEmpty> + <CommandGroup> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => { + field.onChange(vendor.id) + setVendorOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + vendor.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex items-center gap-2"> + <Badge variant="outline">{vendor.vendorCode}</Badge> + <span>{vendor.vendorName}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재구분 */} + <FormField + control={form.control} + name="materialType" + render={({ field }) => ( + <FormItem> + <FormLabel>자재구분</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="자재구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {EVALUATION_TARGET_FILTER_OPTIONS.MATERIAL_TYPES.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 관리자 의견 */} + <FormField + control={form.control} + name="adminComment" + render={({ field }) => ( + <FormItem> + <FormLabel>관리자 의견 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="관리자 의견을 입력하세요..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* L/D 클레임 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">L/D 클레임 정보</CardTitle> + <p className="text-sm text-muted-foreground"> + 지연 배송(Late Delivery) 클레임 관련 정보를 입력하세요. + </p> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-3 gap-4"> + {/* 클레임 건수 */} + <FormField + control={form.control} + name="ldClaimCount" + render={({ field }) => ( + <FormItem> + <FormLabel>클레임 건수</FormLabel> + <FormControl> + <Input + type="number" + min="0" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 클레임 금액 */} + <FormField + control={form.control} + name="ldClaimAmount" + render={({ field }) => ( + <FormItem> + <FormLabel>클레임 금액</FormLabel> + <FormControl> + <Input + type="number" + min="0" + step="0.01" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 통화단위 */} + <FormField + control={form.control} + name="ldClaimCurrency" + render={({ field }) => ( + <FormItem> + <FormLabel>통화단위</FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="통화 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="KRW">KRW (원)</SelectItem> + <SelectItem value="USD">USD (달러)</SelectItem> + <SelectItem value="EUR">EUR (유로)</SelectItem> + <SelectItem value="JPY">JPY (엔)</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + {/* 담당자 지정 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">담당자 지정</CardTitle> + <p className="text-sm text-muted-foreground"> + 각 부서별로 담당자를 지정해주세요. 최소 1명 이상 지정해야 합니다. + </p> + </CardHeader> + <CardContent> + {departments.length === 0 ? ( + <div className="flex items-center justify-center py-8"> + <div className="flex items-center gap-2 text-muted-foreground"> + <Loader2 className="h-4 w-4 animate-spin" /> + 부서 정보를 불러오는 중... + </div> + </div> + ) : ( + <div className="space-y-4"> + {departments.map((department, index) => { + const selectedReviewer = reviewers.find(r => r.id === form.watch(`reviewers.${index}.reviewerUserId`)) + const filteredReviewers = getFilteredReviewers(reviewerSearches[department.code] || "") + const isDisabled = isDepartmentDisabled(department.code) + + return ( + <FormField + key={department.code} + control={form.control} + name={`reviewers.${index}.reviewerUserId`} + render={({ field }) => ( + <FormItem> + <FormLabel className={isDisabled ? "text-muted-foreground" : ""}> + {department.name} + {isDisabled && ( + <span className="text-xs ml-2 text-muted-foreground"> + (벌크 자재 시 비활성화) + </span> + )} + </FormLabel> + <Popover + open={!isDisabled && (reviewerOpens[department.code] || false)} + onOpenChange={(open) => !isDisabled && setReviewerOpens(prev => ({...prev, [department.code]: open}))} + > + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={reviewerOpens[department.code]} + className={cn( + "w-full justify-between", + isDisabled && "opacity-50 cursor-not-allowed" + )} + disabled={isDisabled} + > + {selectedReviewer && !isDisabled ? ( + <span className="flex items-center gap-2"> + <span>{selectedReviewer.name}</span> + <span className="text-xs text-muted-foreground"> + ({selectedReviewer.email}) + </span> + </span> + ) : ( + isDisabled ? "벌크 자재 시 비활성화" : "담당자 선택 (선택사항)" + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput + placeholder="담당자 검색..." + value={reviewerSearches[department.code] || ""} + onValueChange={(value) => setReviewerSearches(prev => ({...prev, [department.code]: value}))} + /> + <CommandList> + <CommandEmpty> + {isLoadingReviewers ? ( + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + 로딩 중... + </div> + ) : ( + "검색 결과가 없습니다." + )} + </CommandEmpty> + <CommandGroup> + <CommandItem + value="선택 안함" + onSelect={() => { + field.onChange(0) + setReviewerOpens(prev => ({...prev, [department.code]: false})) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + field.value === 0 ? "opacity-100" : "opacity-0" + )} + /> + 선택 안함 + </CommandItem> + {filteredReviewers.map((reviewer) => ( + <CommandItem + key={reviewer.id} + value={`${reviewer.name} ${reviewer.email}`} + onSelect={() => { + field.onChange(reviewer.id) + setReviewerOpens(prev => ({...prev, [department.code]: false})) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + reviewer.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex items-center gap-2"> + <span>{reviewer.name}</span> + <span className="text-xs text-muted-foreground"> + ({reviewer.email}) + </span> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + ) + })} + </div> + )} + </CardContent> + </Card> + </div> + </div> + + {/* 고정 버튼 영역 */} + <div className="flex-shrink-0 flex justify-end gap-3 pt-4 border-t"> + <Button + type="button" + variant="outline" + onClick={() => handleOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting} + > + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 생성 + </Button> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
