diff options
Diffstat (limited to 'lib/vendor-candidates/table/candidates-table-floating-bar.tsx')
| -rw-r--r-- | lib/vendor-candidates/table/candidates-table-floating-bar.tsx | 416 |
1 files changed, 237 insertions, 179 deletions
diff --git a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx index 2696292d..baf4a583 100644 --- a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx +++ b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx @@ -30,37 +30,48 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { Kbd } from "@/components/kbd" +import { useSession } from "next-auth/react" // next-auth 세션 훅 import { ActionConfirmDialog } from "@/components/ui/action-dialog" -import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors" -import { bulkUpdateVendorCandidateStatus, removeCandidates, updateVendorCandidate } from "../service" +import { VendorCandidatesWithVendorInfo, vendorCandidates } from "@/db/schema/vendors" +import { + bulkUpdateVendorCandidateStatus, + removeCandidates, +} from "../service" +/** + * 테이블 상단/하단에 고정되는 Floating Bar + * 상태 일괄 변경, 초대, 삭제, Export 등을 수행 + */ interface CandidatesTableFloatingBarProps { - table: Table<VendorCandidates> + table: Table<VendorCandidatesWithVendorInfo> } -export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloatingBarProps) { +export function VendorCandidateTableFloatingBar({ + table, +}: CandidatesTableFloatingBarProps) { const rows = table.getFilteredSelectedRowModel().rows + const { data: session, status } = useSession() + // React 18의 startTransition 사용 (isPending으로 트랜지션 상태 확인) const [isPending, startTransition] = React.useTransition() const [action, setAction] = React.useState< "update-status" | "export" | "delete" | "invite" >() const [popoverOpen, setPopoverOpen] = React.useState(false) - // Clear selection on Escape key press + // ESC 키로 selection 해제 React.useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { table.toggleAllRowsSelected(false) } } - window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [table]) - // 공용 confirm dialog state + // 공용 Confirm Dialog (ActionConfirmDialog) 제어 const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) const [confirmProps, setConfirmProps] = React.useState<{ title: string @@ -69,25 +80,41 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati }>({ title: "", description: "", - onConfirm: () => { }, + onConfirm: () => {}, }) - // 1) "삭제" Confirm 열기 + /** + * 1) 삭제 버튼 클릭 시 Confirm Dialog 열기 + */ function handleDeleteConfirm() { setAction("delete") + setConfirmProps({ - title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`, + title: `Delete ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + }?`, description: "This action cannot be undone.", onConfirm: async () => { startTransition(async () => { - const { error } = await removeCandidates({ - ids: rows.map((row) => row.original.id), - }) + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + + // removeCandidates 호출 시 userId를 넘긴다고 가정 + const { error } = await removeCandidates( + { + ids: rows.map((row) => row.original.id), + }, + userId + ) + if (error) { toast.error(error) return } - toast.success("Users deleted") + toast.success("Candidates deleted successfully") table.toggleAllRowsSelected(false) setConfirmDialogOpen(false) }) @@ -96,43 +123,71 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati setConfirmDialogOpen(true) } - // 2) 상태 업데이트 - function handleSelectStatus(newStatus: VendorCandidates["status"]) { + /** + * 2) 선택된 후보들의 상태 일괄 업데이트 + */ + function handleSelectStatus(newStatus: VendorCandidatesWithVendorInfo["status"]) { setAction("update-status") setConfirmProps({ - title: `Update ${rows.length} candidate${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + title: `Update ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + } with status: ${newStatus}?`, description: "This action will override their current status.", onConfirm: async () => { startTransition(async () => { + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + const { error } = await bulkUpdateVendorCandidateStatus({ ids: rows.map((row) => row.original.id), status: newStatus, + userId, + comment: `Bulk status update to ${newStatus}`, }) + if (error) { toast.error(error) return } toast.success("Candidates updated") setConfirmDialogOpen(false) + table.toggleAllRowsSelected(false) }) }, }) setConfirmDialogOpen(true) } - // 3) 초대하기 (INVITED 상태로 바꾸고 이메일 전송) + /** + * 3) 초대하기 (status = "INVITED" + 이메일 발송) + */ function handleInvite() { setAction("invite") setConfirmProps({ - title: `Invite ${rows.length} candidate${rows.length > 1 ? "s" : ""}?`, - description: "This will change their status to INVITED and send invitation emails.", + title: `Invite ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + }?`, + description: + "This will change their status to INVITED and send invitation emails.", onConfirm: async () => { startTransition(async () => { + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + const { error } = await bulkUpdateVendorCandidateStatus({ ids: rows.map((row) => row.original.id), status: "INVITED", + userId, + comment: "Bulk invite action", }) + if (error) { toast.error(error) return @@ -147,166 +202,168 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati } return ( - <Portal > - <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> - <div className="w-full overflow-x-auto"> - <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> - <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> - <span className="whitespace-nowrap text-xs"> - {rows.length} selected - </span> - <Separator orientation="vertical" className="ml-2 mr-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="size-5 hover:border" - onClick={() => table.toggleAllRowsSelected(false)} - > - <X className="size-3.5 shrink-0" aria-hidden="true" /> - </Button> - </TooltipTrigger> - <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> - <p className="mr-2">Clear selection</p> - <Kbd abbrTitle="Escape" variant="outline"> - Esc - </Kbd> - </TooltipContent> - </Tooltip> - </div> - <Separator orientation="vertical" className="hidden h-5 sm:block" /> - <div className="flex items-center gap-1.5"> - {/* 초대하기 버튼 (새로 추가) */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="sm" - className="h-7 border" - onClick={handleInvite} - disabled={isPending} - > - {isPending && action === "invite" ? ( - <Loader - className="mr-1 size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <Mail className="mr-1 size-3.5" aria-hidden="true" /> - )} - <span>Invite</span> - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Send invitation emails</p> - </TooltipContent> - </Tooltip> + <> + {/* 선택된 row가 있을 때 표시되는 Floating Bar */} + <div className="flex justify-center w-full my-4"> + <div className="flex items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + {/* 선택된 갯수 표시 + Clear selection 버튼 */} + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> - <Select - onValueChange={(value: VendorCandidates["status"]) => { - handleSelectStatus(value) - }} - > - <Tooltip> - <SelectTrigger asChild> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" - disabled={isPending} - > - {isPending && action === "update-status" ? ( - <Loader - className="size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <CheckCircle2 - className="size-3.5" - aria-hidden="true" - /> - )} - </Button> - </TooltipTrigger> - </SelectTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Update status</p> - </TooltipContent> - </Tooltip> - <SelectContent align="center"> - <SelectGroup> - {vendorCandidates.status.enumValues.map((status) => ( - <SelectItem - key={status} - value={status} - className="capitalize" - > - {status} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={() => { - setAction("export") + {/* 우측 액션들: 초대, 상태변경, Export, 삭제 */} + <div className="flex items-center gap-1.5"> + {/* 초대하기 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="sm" + className="h-7 border" + onClick={handleInvite} + disabled={isPending} + > + {isPending && action === "invite" ? ( + <Loader + className="mr-1 size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Mail className="mr-1 size-3.5" aria-hidden="true" /> + )} + <span>Invite</span> + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Send invitation emails</p> + </TooltipContent> + </Tooltip> - startTransition(() => { - exportTableToExcel(table, { - excludeColumns: ["select", "actions"], - onlySelected: true, - }) - }) - }} - disabled={isPending} - > - {isPending && action === "export" ? ( - <Loader - className="size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <Download className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Export candidates</p> - </TooltipContent> - </Tooltip> - + {/* 상태 업데이트 (Select) */} + <Select + onValueChange={(value: VendorCandidatesWithVendorInfo["status"]) => { + handleSelectStatus(value) + }} + > <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={handleDeleteConfirm} - disabled={isPending} - > - {isPending && action === "delete" ? ( - <Loader - className="size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <Trash2 className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-status" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <CheckCircle2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Delete candidates</p> + <p>Update status</p> </TooltipContent> </Tooltip> - </div> + <SelectContent align="center"> + <SelectGroup> + {vendorCandidates.status.enumValues.map((status) => ( + <SelectItem + key={status} + value={status} + className="capitalize" + > + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + + {/* Export 버튼 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export candidates</p> + </TooltipContent> + </Tooltip> + + {/* 삭제 버튼 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete candidates</p> + </TooltipContent> + </Tooltip> </div> </div> </div> @@ -318,7 +375,10 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati title={confirmProps.title} description={confirmProps.description} onConfirm={confirmProps.onConfirm} - isLoading={isPending && (action === "delete" || action === "update-status" || action === "invite")} + isLoading={ + isPending && + (action === "delete" || action === "update-status" || action === "invite") + } confirmLabel={ action === "delete" ? "Delete" @@ -328,10 +388,8 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati ? "Invite" : "Confirm" } - confirmVariant={ - action === "delete" ? "destructive" : "default" - } + confirmVariant={action === "delete" ? "destructive" : "default"} /> - </Portal> + </> ) -}
\ No newline at end of file +} |
