From a75541e1a1aea596bfca2a435f39133b9b72f193 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 23 Jun 2025 09:00:56 +0000 Subject: (최겸) 기술영업 벤더 후보관리 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/invite-candidates-dialog.tsx | 230 +++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx (limited to 'lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx') diff --git a/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx b/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx new file mode 100644 index 00000000..570cf96a --- /dev/null +++ b/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx @@ -0,0 +1,230 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Mail, AlertCircle, XCircle } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { + Alert, + AlertTitle, + AlertDescription +} from "@/components/ui/alert" + +import { VendorCandidates } from "@/db/schema/vendors" +import { bulkUpdateVendorCandidateStatus } from "../service" +import { useSession } from "next-auth/react" // next-auth 세션 훅 + +interface InviteCandidatesDialogProps + extends React.ComponentPropsWithoutRef { + candidates: Row["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function InviteCandidatesDialog({ + candidates, + showTrigger = true, + onSuccess, + ...props +}: InviteCandidatesDialogProps) { + const [isInvitePending, startInviteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session, status } = useSession() + + // 후보자를 상태별로 분류 + const discardedCandidates = candidates.filter(candidate => candidate.status === "DISCARDED") + const nonDiscardedCandidates = candidates.filter(candidate => candidate.status !== "DISCARDED") + + // 이메일 유무에 따라 초대 가능한 후보자 분류 (DISCARDED가 아닌 후보자 중에서) + const candidatesWithEmail = nonDiscardedCandidates.filter(candidate => candidate.contactEmail) + const candidatesWithoutEmail = nonDiscardedCandidates.filter(candidate => !candidate.contactEmail) + + // 각 카테고리 수 + const invitableCount = candidatesWithEmail.length + const hasUninvitableCandidates = candidatesWithoutEmail.length > 0 + const hasDiscardedCandidates = discardedCandidates.length > 0 + + function onInvite() { + startInviteTransition(async () => { + // 이메일이 있고 DISCARDED가 아닌 후보자만 상태 업데이트 + + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + + const { error } = await bulkUpdateVendorCandidateStatus({ + ids: candidatesWithEmail.map((candidate) => candidate.id), + status: "INVITED", + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + + if (invitableCount === 0) { + toast.warning("No invitation sent - no eligible candidates with email addresses") + } else { + let skipMessage = "" + + if (hasUninvitableCandidates && hasDiscardedCandidates) { + skipMessage = ` ${candidatesWithoutEmail.length} candidates without email and ${discardedCandidates.length} discarded candidates were skipped.` + } else if (hasUninvitableCandidates) { + skipMessage = ` ${candidatesWithoutEmail.length} candidates without email were skipped.` + } else if (hasDiscardedCandidates) { + skipMessage = ` ${discardedCandidates.length} discarded candidates were skipped.` + } + + toast.success(`Invitation emails sent to ${invitableCount} candidates.${skipMessage}`) + } + + onSuccess?.() + }) + } + + // 초대 버튼 비활성화 조건 + const disableInviteButton = isInvitePending || invitableCount === 0 + + const DialogComponent = ( + <> +
+ {/* 이메일 없는 후보자 알림 */} + {hasUninvitableCandidates && ( + + + Missing Email Addresses + + {candidatesWithoutEmail.length} candidate{candidatesWithoutEmail.length > 1 ? 's' : ''} {candidatesWithoutEmail.length > 1 ? 'don't' : 'doesn't'} have email addresses and won't receive invitations. + + + )} + + {/* 폐기된 후보자 알림 */} + {hasDiscardedCandidates && ( + + + Discarded Candidates + + {discardedCandidates.length} candidate{discardedCandidates.length > 1 ? 's have' : ' has'} been discarded and won't receive invitations. + + + )} + + + {invitableCount > 0 ? ( + <> + This will send invitation emails to{" "} + {invitableCount} + {invitableCount === 1 ? " candidate" : " candidates"} and change their status to INVITED. + + ) : ( + <> + No candidates can be invited because none of the selected candidates have valid email addresses or they have been discarded. + + )} + +
+ + ) + + if (isDesktop) { + return ( + + {showTrigger ? ( + + + + ) : null} + + + Send invitations? + + {DialogComponent} + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + Send invitations? + + {DialogComponent} + + + + + + + + + ) +} \ No newline at end of file -- cgit v1.2.3