diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-20 11:37:31 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-20 11:37:31 +0000 |
| commit | aa86729f9a2ab95346a2851e3837de1c367aae17 (patch) | |
| tree | b601b18b6724f2fb449c7fa9ea50cbd652a8077d /lib/evaluation-target-list/table/update-evaluation-target.tsx | |
| parent | 95bbe9c583ff841220da1267630e7b2025fc36dc (diff) | |
(대표님) 20250620 작업사항
Diffstat (limited to 'lib/evaluation-target-list/table/update-evaluation-target.tsx')
| -rw-r--r-- | lib/evaluation-target-list/table/update-evaluation-target.tsx | 760 |
1 files changed, 760 insertions, 0 deletions
diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx new file mode 100644 index 00000000..0d56addb --- /dev/null +++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx @@ -0,0 +1,760 @@ +"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, X } from "lucide-react" +import { toast } from "sonner" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +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 { + updateEvaluationTarget, + getAvailableReviewers, + getDepartmentInfo, + type UpdateEvaluationTargetInput, +} from "../service" +import { EvaluationTargetWithDepartments } from "@/db/schema" + +// 편집 가능한 필드들에 대한 스키마 +const editEvaluationTargetSchema = z.object({ + adminComment: z.string().optional(), + consolidatedComment: z.string().optional(), + ldClaimCount: z.number().min(0).optional(), + ldClaimAmount: z.number().min(0).optional(), + ldClaimCurrency: z.enum(["KRW", "USD", "EUR", "JPY"]).optional(), + consensusStatus: z.boolean().nullable().optional(), + orderIsApproved: z.boolean().nullable().optional(), + procurementIsApproved: z.boolean().nullable().optional(), + qualityIsApproved: z.boolean().nullable().optional(), + designIsApproved: z.boolean().nullable().optional(), + csIsApproved: z.boolean().nullable().optional(), + // 담당자 정보 수정 + orderReviewerEmail: z.string().optional(), + procurementReviewerEmail: z.string().optional(), + qualityReviewerEmail: z.string().optional(), + designReviewerEmail: z.string().optional(), + csReviewerEmail: z.string().optional(), +}) + +type EditEvaluationTargetFormValues = z.infer<typeof editEvaluationTargetSchema> + +interface EditEvaluationTargetSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluationTarget: EvaluationTargetWithDepartments | null +} + +// 권한 타입 정의 +type PermissionLevel = "none" | "department" | "admin" + +interface UserPermissions { + level: PermissionLevel + editableApprovals: string[] // 편집 가능한 approval 필드들 +} + +export function EditEvaluationTargetSheet({ + open, + onOpenChange, + evaluationTarget, +}: EditEvaluationTargetSheetProps) { + const router = useRouter() + const [isSubmitting, setIsSubmitting] = React.useState(false) + const { data: session } = useSession() + + // 담당자 관련 상태 + 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 userPermissions = React.useMemo((): UserPermissions => { + if (!session?.user || !evaluationTarget) { + return { level: "none", editableApprovals: [] } + } + + const userEmail = session.user.email + const userRole = session.user.role + + // 평가관리자는 모든 권한 + if (userRole === "평가관리자") { + return { + level: "admin", + editableApprovals: [ + "orderIsApproved", + "procurementIsApproved", + "qualityIsApproved", + "designIsApproved", + "csIsApproved" + ] + } + } + + // 부서별 담당자 권한 확인 + const editableApprovals: string[] = [] + + if (evaluationTarget.orderReviewerEmail === userEmail) { + editableApprovals.push("orderIsApproved") + } + if (evaluationTarget.procurementReviewerEmail === userEmail) { + editableApprovals.push("procurementIsApproved") + } + if (evaluationTarget.qualityReviewerEmail === userEmail) { + editableApprovals.push("qualityIsApproved") + } + if (evaluationTarget.designReviewerEmail === userEmail) { + editableApprovals.push("designIsApproved") + } + if (evaluationTarget.csReviewerEmail === userEmail) { + editableApprovals.push("csIsApproved") + } + + return { + level: editableApprovals.length > 0 ? "department" : "none", + editableApprovals + } + }, [session, evaluationTarget]) + + const form = useForm<EditEvaluationTargetFormValues>({ + resolver: zodResolver(editEvaluationTargetSchema), + defaultValues: { + adminComment: "", + consolidatedComment: "", + ldClaimCount: 0, + ldClaimAmount: 0, + ldClaimCurrency: "KRW", + consensusStatus: null, + orderIsApproved: null, + procurementIsApproved: null, + qualityIsApproved: null, + designIsApproved: null, + csIsApproved: null, + orderReviewerEmail: "", + procurementReviewerEmail: "", + qualityReviewerEmail: "", + designReviewerEmail: "", + csReviewerEmail: "", + }, + }) + + // 부서 정보 로드 + const loadDepartments = React.useCallback(async () => { + try { + const departmentList = await getDepartmentInfo() + setDepartments(departmentList) + } catch (error) { + console.error("Error loading departments:", error) + toast.error("부서 정보를 불러오는데 실패했습니다.") + } + }, []) + + // 담당자 목록 로드 + 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 && evaluationTarget) { + loadDepartments() + loadReviewers() + + // 폼에 기존 데이터 설정 + form.reset({ + adminComment: evaluationTarget.adminComment || "", + consolidatedComment: evaluationTarget.consolidatedComment || "", + ldClaimCount: evaluationTarget.ldClaimCount || 0, + ldClaimAmount: parseFloat(evaluationTarget.ldClaimAmount || "0"), + ldClaimCurrency: evaluationTarget.ldClaimCurrency || "KRW", + consensusStatus: evaluationTarget.consensusStatus, + orderIsApproved: evaluationTarget.orderIsApproved, + procurementIsApproved: evaluationTarget.procurementIsApproved, + qualityIsApproved: evaluationTarget.qualityIsApproved, + designIsApproved: evaluationTarget.designIsApproved, + csIsApproved: evaluationTarget.csIsApproved, + orderReviewerEmail: evaluationTarget.orderReviewerEmail || "", + procurementReviewerEmail: evaluationTarget.procurementReviewerEmail || "", + qualityReviewerEmail: evaluationTarget.qualityReviewerEmail || "", + designReviewerEmail: evaluationTarget.designReviewerEmail || "", + csReviewerEmail: evaluationTarget.csReviewerEmail || "", + }) + } + }, [open, evaluationTarget, form]) + + // 폼 제출 + async function onSubmit(data: EditEvaluationTargetFormValues) { + if (!evaluationTarget) return + + setIsSubmitting(true) + try { + const input: UpdateEvaluationTargetInput = { + id: evaluationTarget.id, + ...data, + } + + console.log("Updating evaluation target:", input) + + const result = await updateEvaluationTarget(input) + + if (result.success) { + toast.success(result.message || "평가 대상이 성공적으로 수정되었습니다.") + onOpenChange(false) + router.refresh() + } else { + toast.error(result.error || "평가 대상 수정에 실패했습니다.") + } + } catch (error) { + console.error("Error updating evaluation target:", error) + toast.error("평가 대상 수정 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 시트 닫기 핸들러 + const handleOpenChange = (open: boolean) => { + onOpenChange(open) + if (!open) { + form.reset() + setReviewerSearches({}) + setReviewerOpens({}) + } + } + + // 담당자 검색 필터링 + 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 canEditField = (fieldName: string): boolean => { + if (userPermissions.level === "admin") return true + if (userPermissions.level === "none") return false + + // 부서 담당자는 자신의 approval만 편집 가능 + return userPermissions.editableApprovals.includes(fieldName) + } + + // 관리자 전용 필드 확인 + const canEditAdminFields = (): boolean => { + return userPermissions.level === "admin" + } + + if (!evaluationTarget) { + return null + } + + // 권한이 없는 경우 + if (userPermissions.level === "none") { + return ( + <Sheet open={open} onOpenChange={handleOpenChange}> + <SheetContent className="sm:max-w-lg overflow-y-auto"> + <SheetHeader> + <SheetTitle>평가 대상 수정</SheetTitle> + <SheetDescription> + 권한이 없어 수정할 수 없습니다. + </SheetDescription> + </SheetHeader> + <div className="mt-6 p-4 bg-muted rounded-lg text-center"> + <p className="text-sm text-muted-foreground"> + 이 평가 대상을 수정할 권한이 없습니다. + </p> + </div> + </SheetContent> + </Sheet> + ) + } + + return ( + <Sheet open={open} onOpenChange={handleOpenChange}> + <SheetContent className="flex flex-col h-full sm:max-w-xl"> + <SheetHeader className="flex-shrink-0 text-left pb-6"> + <SheetTitle>평가 대상 수정</SheetTitle> + <SheetDescription> + 평가 대상 정보를 수정합니다. + {userPermissions.level === "department" && ( + <div className="mt-2 p-2 bg-blue-50 rounded text-sm"> + <strong>부서 담당자 권한:</strong> 해당 부서의 평가 항목만 수정 가능합니다. + </div> + )} + {userPermissions.level === "admin" && ( + <div className="mt-2 p-2 bg-green-50 rounded text-sm"> + <strong>평가관리자 권한:</strong> 모든 항목을 수정할 수 있습니다. + </div> + )} + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + {/* 스크롤 가능한 컨텐츠 영역 */} + <div className="flex-1 overflow-y-auto pr-2 -mr-2"> + <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 text-sm"> + <div> + <span className="font-medium">평가년도:</span> {evaluationTarget.evaluationYear} + </div> + <div> + <span className="font-medium">구분:</span> {evaluationTarget.division === "PLANT" ? "해양" : "조선"} + </div> + <div> + <span className="font-medium">벤더 코드:</span> {evaluationTarget.vendorCode} + </div> + <div> + <span className="font-medium">벤더명:</span> {evaluationTarget.vendorName} + </div> + <div> + <span className="font-medium">자재구분:</span> {evaluationTarget.materialType} + </div> + <div> + <span className="font-medium">상태:</span> {evaluationTarget.status} + </div> + </div> + </CardContent> + </Card> + + {/* L/D 클레임 정보 (관리자만) */} + {canEditAdminFields() && ( + <Card> + <CardHeader> + <CardTitle className="text-lg">L/D 클레임 정보</CardTitle> + </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" + {...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" + {...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} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue /> + </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> + </CardHeader> + <CardContent className="space-y-4"> + + {/* 의견 일치 여부 (관리자만) */} + {canEditAdminFields() && ( + <FormField + control={form.control} + name="consensusStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>의견 일치 여부</FormLabel> + <Select + onValueChange={(value) => { + if (value === "null") field.onChange(null) + else field.onChange(value === "true") + }} + value={field.value === null ? "null" : field.value.toString()} + > + <FormControl> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="null">검토 중</SelectItem> + <SelectItem value="true">의견 일치</SelectItem> + <SelectItem value="false">의견 불일치</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 각 부서별 평가 */} + <div className="grid grid-cols-1 gap-4"> + {[ + { key: "orderIsApproved", label: "주문 부서 평가", email: evaluationTarget.orderReviewerEmail }, + { key: "procurementIsApproved", label: "조달 부서 평가", email: evaluationTarget.procurementReviewerEmail }, + { key: "qualityIsApproved", label: "품질 부서 평가", email: evaluationTarget.qualityReviewerEmail }, + { key: "designIsApproved", label: "설계 부서 평가", email: evaluationTarget.designReviewerEmail }, + { key: "csIsApproved", label: "CS 부서 평가", email: evaluationTarget.csReviewerEmail }, + ].map(({ key, label, email }) => ( + <FormField + key={key} + control={form.control} + name={key as keyof EditEvaluationTargetFormValues} + render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center justify-between"> + <span>{label}</span> + {email && ( + <span className="text-xs text-muted-foreground"> + 담당자: {email} + </span> + )} + </FormLabel> + <Select + onValueChange={(value) => { + if (value === "null") field.onChange(null) + else field.onChange(value === "true") + }} + value={field.value === null ? "null" : field.value?.toString()} + disabled={!canEditField(key)} + > + <FormControl> + <SelectTrigger className={!canEditField(key) ? "opacity-50" : ""}> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="null">대기중</SelectItem> + <SelectItem value="true">평가대상 맞음</SelectItem> + <SelectItem value="false">평가대상 아님</SelectItem> + </SelectContent> + </Select> + {!canEditField(key) && ( + <p className="text-xs text-muted-foreground"> + 편집 권한이 없습니다. + </p> + )} + <FormMessage /> + </FormItem> + )} + /> + ))} + </div> + </CardContent> + </Card> + + {/* 의견 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">의견</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 관리자 의견 (관리자만) */} + {canEditAdminFields() && ( + <FormField + control={form.control} + name="adminComment" + render={({ field }) => ( + <FormItem> + <FormLabel>관리자 의견</FormLabel> + <FormControl> + <Textarea + placeholder="관리자 의견을 입력하세요..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 종합 의견 (모든 권한자) */} + <FormField + control={form.control} + name="consolidatedComment" + render={({ field }) => ( + <FormItem> + <FormLabel>종합 의견</FormLabel> + <FormControl> + <Textarea + placeholder="종합 의견을 입력하세요..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + + {/* 담당자 변경 (관리자만) */} + {canEditAdminFields() && departments.length > 0 && ( + <Card> + <CardHeader> + <CardTitle className="text-lg">담당자 변경</CardTitle> + <p className="text-sm text-muted-foreground"> + 각 부서별 담당자를 변경할 수 있습니다. + </p> + </CardHeader> + <CardContent className="space-y-4"> + {[ + { key: "orderReviewerEmail", label: "주문 부서 담당자", current: evaluationTarget.orderReviewerEmail }, + { key: "procurementReviewerEmail", label: "조달 부서 담당자", current: evaluationTarget.procurementReviewerEmail }, + { key: "qualityReviewerEmail", label: "품질 부서 담당자", current: evaluationTarget.qualityReviewerEmail }, + { key: "designReviewerEmail", label: "설계 부서 담당자", current: evaluationTarget.designReviewerEmail }, + { key: "csReviewerEmail", label: "CS 부서 담당자", current: evaluationTarget.csReviewerEmail }, + ].map(({ key, label, current }) => { + const selectedReviewer = reviewers.find(r => r.email === form.watch(key as keyof EditEvaluationTargetFormValues)) + const filteredReviewers = getFilteredReviewers(reviewerSearches[key] || "") + + return ( + <FormField + key={key} + control={form.control} + name={key as keyof EditEvaluationTargetFormValues} + render={({ field }) => ( + <FormItem> + <FormLabel> + {label} + {current && ( + <span className="text-xs text-muted-foreground ml-2"> + (현재: {current}) + </span> + )} + </FormLabel> + <Popover + open={reviewerOpens[key] || false} + onOpenChange={(open) => setReviewerOpens(prev => ({...prev, [key]: open}))} + > + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={reviewerOpens[key]} + className="w-full justify-between" + > + {selectedReviewer ? ( + <span className="flex items-center gap-2"> + <span>{selectedReviewer.name}</span> + <span className="text-xs text-muted-foreground"> + ({selectedReviewer.email}) + </span> + </span> + ) : field.value ? ( + field.value + ) : ( + "담당자 선택..." + )} + <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[key] || ""} + onValueChange={(value) => setReviewerSearches(prev => ({...prev, [key]: 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("") + setReviewerOpens(prev => ({ ...prev, [key]: false })) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + !field.value ? "opacity-100" : "opacity-0" + )} + /> + 선택 안함 + </CommandItem> + {filteredReviewers.map((reviewer) => ( + <CommandItem + key={reviewer.id} + value={`${reviewer.name} ${reviewer.email}`} + onSelect={() => { + field.onChange(reviewer.email) + setReviewerOpens(prev => ({...prev, [key]: false})) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + reviewer.email === 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> + )} + /> + ) + })} + </CardContent> + </Card> + )} + </div> + </div> + + {/* 고정된 버튼 영역 */} + <div className="flex-shrink-0 flex justify-end gap-3 pt-6 mt-6 border-t bg-background"> + <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> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file |
