diff options
| author | joonhoekim <26rote@gmail.com> | 2025-06-24 01:51:59 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-06-24 01:51:59 +0000 |
| commit | 6824e097d768f724cf439b410ccfb1ab9685ac98 (patch) | |
| tree | 1f297313637878e7a4ad6c89b84d5a2c3e9eb650 /lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx | |
| parent | f4825dd3853188de4688fb4a56c0f4e847da314b (diff) | |
| parent | 4e63d8427d26d0d1b366ddc53650e15f3481fc75 (diff) | |
(merge) 대표님/최겸 작업사항 머지
Diffstat (limited to 'lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx')
| -rw-r--r-- | lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx | 230 |
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't' : 'doesn't'} have email addresses and won'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'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 |
