summaryrefslogtreecommitdiff
path: root/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx')
-rw-r--r--lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx230
1 files changed, 230 insertions, 0 deletions
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<typeof Dialog> {
+ candidates: Row<VendorCandidates>["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 = (
+ <>
+ <div className="space-y-4">
+ {/* 이메일 없는 후보자 알림 */}
+ {hasUninvitableCandidates && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertTitle>Missing Email Addresses</AlertTitle>
+ <AlertDescription>
+ {candidatesWithoutEmail.length} candidate{candidatesWithoutEmail.length > 1 ? 's' : ''} {candidatesWithoutEmail.length > 1 ? 'don&apos;t' : 'doesn&apos;t'} have email addresses and won&apos;t receive invitations.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 폐기된 후보자 알림 */}
+ {hasDiscardedCandidates && (
+ <Alert variant="destructive">
+ <XCircle className="h-4 w-4" />
+ <AlertTitle>Discarded Candidates</AlertTitle>
+ <AlertDescription>
+ {discardedCandidates.length} candidate{discardedCandidates.length > 1 ? 's have' : ' has'} been discarded and won&apos;t receive invitations.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ <DialogDescription>
+ {invitableCount > 0 ? (
+ <>
+ This will send invitation emails to{" "}
+ <span className="font-medium">{invitableCount}</span>
+ {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.
+ </>
+ )}
+ </DialogDescription>
+ </div>
+ </>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Mail className="size-4" aria-hidden="true" />
+ Invite ({candidates.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Send invitations?</DialogTitle>
+ </DialogHeader>
+ {DialogComponent}
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Invite selected vendors"
+ variant="default"
+ onClick={onInvite}
+ disabled={disableInviteButton}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Send Invitations
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Mail className="size-4" aria-hidden="true" />
+ Invite ({candidates.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Send invitations?</DrawerTitle>
+ </DrawerHeader>
+ {DialogComponent}
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Invite selected vendors"
+ variant="default"
+ onClick={onInvite}
+ disabled={disableInviteButton}
+ >
+ {isInvitePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Send Invitations
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file