diff options
Diffstat (limited to 'lib/vendor-candidates/table')
11 files changed, 971 insertions, 351 deletions
diff --git a/lib/vendor-candidates/table/add-candidates-dialog.tsx b/lib/vendor-candidates/table/add-candidates-dialog.tsx index db475064..733d3716 100644 --- a/lib/vendor-candidates/table/add-candidates-dialog.tsx +++ b/lib/vendor-candidates/table/add-candidates-dialog.tsx @@ -8,10 +8,13 @@ import i18nIsoCountries from "i18n-iso-countries" import enLocale from "i18n-iso-countries/langs/en.json" import koLocale from "i18n-iso-countries/langs/ko.json" import { cn } from "@/lib/utils" +import { useSession } from "next-auth/react" // next-auth 세션 훅 추가 import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { useToast } from "@/hooks/use-toast" import { Popover, PopoverContent, @@ -36,19 +39,9 @@ import { FormMessage, } from "@/components/ui/form" -// shadcn/ui Select -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { createVendorCandidateSchema, CreateVendorCandidateSchema } from "../validations" import { createVendorCandidate } from "../service" -import { vendorCandidates } from "@/db/schema/vendors" // Register locales for countries i18nIsoCountries.registerLocale(enLocale) @@ -65,34 +58,65 @@ const countryArray = Object.entries(countryMap).map(([code, label]) => ({ export function AddCandidateDialog() { const [open, setOpen] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) + const { toast } = useToast() + const { data: session, status } = useSession() // react-hook-form 세팅 const form = useForm<CreateVendorCandidateSchema>({ resolver: zodResolver(createVendorCandidateSchema), defaultValues: { companyName: "", - contactEmail: "", + contactEmail: "", // 이제 빈 문자열이 허용됨 contactPhone: "", + taxId: "", + address: "", country: "", source: "", - status: "COLLECTED", // Default status set to COLLECTED + items: "", + remark: "", + status: "COLLECTED", }, - }) + }); async function onSubmit(data: CreateVendorCandidateSchema) { setIsSubmitting(true) try { - const result = await createVendorCandidate(data) + // 세션 유효성 검사 + if (!session || !session.user || !session.user.id) { + toast({ + title: "인증 오류", + description: "로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.", + variant: "destructive", + }) + return + } + + // userId 추출 (세션 구조에 따라 조정 필요) + const userId = session.user.id + + const result = await createVendorCandidate(data, Number(userId)) if (result.error) { - alert(`에러: ${result.error}`) + toast({ + title: "오류 발생", + description: result.error, + variant: "destructive", + }) return } // 성공 시 모달 닫고 폼 리셋 + toast({ + title: "등록 완료", + description: "협력업체 후보가 성공적으로 등록되었습니다.", + }) form.reset() setOpen(false) } catch (error) { console.error("Failed to create vendor candidate:", error) - alert("An unexpected error occurred") + toast({ + title: "오류 발생", + description: "예상치 못한 오류가 발생했습니다.", + variant: "destructive", + }) } finally { setIsSubmitting(false) } @@ -114,7 +138,7 @@ export function AddCandidateDialog() { </Button> </DialogTrigger> - <DialogContent className="sm:max-w-[425px]"> + <DialogContent className="sm:max-w-[525px]"> <DialogHeader> <DialogTitle>Create New Vendor Candidate</DialogTitle> <DialogDescription> @@ -124,17 +148,15 @@ export function AddCandidateDialog() { {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="space-y-4 py-4"> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Company Name 필드 */} <FormField control={form.control} name="companyName" render={({ field }) => ( <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - Company Name - </FormLabel> + <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Enter company name" @@ -147,15 +169,32 @@ export function AddCandidateDialog() { )} /> + {/* Tax ID 필드 (새로 추가) */} + <FormField + control={form.control} + name="taxId" + render={({ field }) => ( + <FormItem> + <FormLabel>Tax ID</FormLabel> + <FormControl> + <Input + placeholder="Tax identification number" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Contact Email 필드 */} <FormField control={form.control} name="contactEmail" render={({ field }) => ( <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - Contact Email - </FormLabel> + <FormLabel>Contact Email</FormLabel> <FormControl> <Input placeholder="email@example.com" @@ -188,6 +227,25 @@ export function AddCandidateDialog() { )} /> + {/* Address 필드 */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem className="col-span-full"> + <FormLabel>Address</FormLabel> + <FormControl> + <Input + placeholder="Company address" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Country 필드 */} <FormField control={form.control} @@ -260,7 +318,7 @@ export function AddCandidateDialog() { name="source" render={({ field }) => ( <FormItem> - <FormLabel>Source</FormLabel> + <FormLabel>Source <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Where this candidate was found" @@ -273,37 +331,46 @@ export function AddCandidateDialog() { )} /> - {/* Status 필드 */} - {/* <FormField + + {/* Items 필드 (새로 추가) */} + <FormField control={form.control} - name="status" + name="items" render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - disabled={isSubmitting} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select status" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - {vendorCandidates.status.enumValues.map((status) => ( - <SelectItem key={status} value={status}> - {status} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> + <FormItem className="col-span-full"> + <FormLabel>Items <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Textarea + placeholder="List of items or products this vendor provides" + className="min-h-[80px]" + {...field} + disabled={isSubmitting} + /> + </FormControl> <FormMessage /> </FormItem> )} - /> */} + /> + + {/* Remark 필드 (새로 추가) */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem className="col-span-full"> + <FormLabel>Remarks</FormLabel> + <FormControl> + <Textarea + placeholder="Additional notes or comments" + className="min-h-[80px]" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> </div> <DialogFooter> diff --git a/lib/vendor-candidates/table/candidates-table-columns.tsx b/lib/vendor-candidates/table/candidates-table-columns.tsx index dc014d4e..113927cf 100644 --- a/lib/vendor-candidates/table/candidates-table-columns.tsx +++ b/lib/vendor-candidates/table/candidates-table-columns.tsx @@ -7,7 +7,7 @@ import { Ellipsis } from "lucide-react" import { toast } from "sonner" import { getErrorMessage } from "@/lib/handle-error" -import { formatDate } from "@/lib/utils" +import { formatDate, formatDateTime } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -24,24 +24,24 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" -import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors" import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils" import { candidateColumnsConfig } from "@/config/candidatesColumnsConfig" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { VendorCandidatesWithVendorInfo } from "@/db/schema" interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidates> | null>> + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>> } /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidates>[] { +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidatesWithVendorInfo>[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorCandidates> = { + const selectColumn: ColumnDef<VendorCandidatesWithVendorInfo> = { id: "select", header: ({ table }) => ( <Checkbox @@ -70,48 +70,54 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC // ---------------------------------------------------------------- // 2) actions 컬럼 (Dropdown 메뉴) // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<VendorCandidates> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - Delete - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } +// "actions" 컬럼 예시 +const actionsColumn: ColumnDef<VendorCandidatesWithVendorInfo> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + 편집 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + 삭제 + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + + {/* 여기서 Log 보기 액션 추가 */} + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "log" })} + > + 감사 로그 보기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, +} + // ---------------------------------------------------------------- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidates>[] } - const groupMap: Record<string, ColumnDef<VendorCandidates>[]> = {} + // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidatesWithVendorInfo>[] } + const groupMap: Record<string, ColumnDef<VendorCandidatesWithVendorInfo>[]> = {} candidateColumnsConfig.forEach((cfg) => { // 만약 group가 없으면 "_noGroup" 처리 @@ -122,11 +128,11 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC } // child column 정의 - const childCol: ColumnDef<VendorCandidates> = { + const childCol: ColumnDef<VendorCandidatesWithVendorInfo> = { accessorKey: cfg.id, enableResizing: true, header: ({ column }) => ( - <DataTableColumnHeader column={column} title={cfg.label} /> + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> ), meta: { excelHeader: cfg.excelHeader, @@ -148,9 +154,9 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC } - if (cfg.id === "createdAt") { + if (cfg.id === "createdAt" ||cfg.id === "updatedAt" ) { const dateVal = cell.getValue() as Date - return formatDate(dateVal) + return formatDateTime(dateVal) } // code etc... @@ -164,7 +170,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC // ---------------------------------------------------------------- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<VendorCandidates>[] = [] + const nestedColumns: ColumnDef<VendorCandidatesWithVendorInfo>[] = [] // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 // 여기서는 그냥 Object.entries 순서 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 +} diff --git a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx index a2229a54..17462841 100644 --- a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx +++ b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx @@ -14,15 +14,15 @@ import { } from "@/components/ui/dropdown-menu" import { AddCandidateDialog } from "./add-candidates-dialog" -import { VendorCandidates } from "@/db/schema/vendors" import { DeleteCandidatesDialog } from "./delete-candidates-dialog" import { InviteCandidatesDialog } from "./invite-candidates-dialog" import { ImportVendorCandidatesButton } from "./import-button" import { exportVendorCandidateTemplate } from "./excel-template-download" +import { VendorCandidatesWithVendorInfo } from "@/db/schema/vendors" interface CandidatesTableToolbarActionsProps { - table: Table<VendorCandidates> + table: Table<VendorCandidatesWithVendorInfo> } export function CandidatesTableToolbarActions({ table }: CandidatesTableToolbarActionsProps) { diff --git a/lib/vendor-candidates/table/candidates-table.tsx b/lib/vendor-candidates/table/candidates-table.tsx index 2c01733c..e36649b5 100644 --- a/lib/vendor-candidates/table/candidates-table.tsx +++ b/lib/vendor-candidates/table/candidates-table.tsx @@ -11,17 +11,17 @@ import { toSentenceCase } from "@/lib/utils" import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" import { useFeatureFlags } from "./feature-flags-provider" import { getVendorCandidateCounts, getVendorCandidates } from "../service" -import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors" +import { vendorCandidates ,VendorCandidatesWithVendorInfo} from "@/db/schema/vendors" import { VendorCandidateTableFloatingBar } from "./candidates-table-floating-bar" import { getColumns } from "./candidates-table-columns" import { CandidatesTableToolbarActions } from "./candidates-table-toolbar-actions" import { DeleteCandidatesDialog } from "./delete-candidates-dialog" import { UpdateCandidateSheet } from "./update-candidate-sheet" import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils" +import { ViewCandidateLogsDialog } from "./view-candidate_logs-dialog" interface VendorCandidatesTableProps { promises: Promise< @@ -41,7 +41,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { const [rowAction, setRowAction] = - React.useState<DataTableRowAction<VendorCandidates> | null>(null) + React.useState<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>(null) const columns = React.useMemo( () => getColumns({ setRowAction }), @@ -59,7 +59,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. */ - const filterFields: DataTableFilterField<VendorCandidates>[] = [ + const filterFields: DataTableFilterField<VendorCandidatesWithVendorInfo>[] = [ { id: "status", @@ -83,7 +83,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. */ - const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidates>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidatesWithVendorInfo>[] = [ { id: "companyName", label: "Company Name", @@ -109,7 +109,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { label: "Status", type: "multi-select", options: vendorCandidates.status.enumValues.map((status) => ({ - label: toSentenceCase(status), + label: (status), value: status, icon: getCandidateStatusIcon(status), count: statusCounts[status], @@ -118,7 +118,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { { id: "createdAt", - label: "Created at", + label: "수집일", type: "date", }, ] @@ -168,6 +168,11 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { showTrigger={false} onSuccess={() => rowAction?.row.toggleSelected(false)} /> + <ViewCandidateLogsDialog + open={rowAction?.type === "log"} + onOpenChange={() => setRowAction(null)} + candidateId={rowAction?.row.original?.id ?? null} + /> </> ) } diff --git a/lib/vendor-candidates/table/delete-candidates-dialog.tsx b/lib/vendor-candidates/table/delete-candidates-dialog.tsx index e9fabf76..bc231109 100644 --- a/lib/vendor-candidates/table/delete-candidates-dialog.tsx +++ b/lib/vendor-candidates/table/delete-candidates-dialog.tsx @@ -28,12 +28,13 @@ import { DrawerTrigger, } from "@/components/ui/drawer" -import { VendorCandidates } from "@/db/schema/vendors" import { removeCandidates } from "../service" +import { VendorCandidatesWithVendorInfo } from "@/db/schema" +import { useSession } from "next-auth/react" // next-auth 세션 훅 interface DeleteCandidatesDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { - candidates: Row<VendorCandidates>["original"][] + candidates: Row<VendorCandidatesWithVendorInfo>["original"][] showTrigger?: boolean onSuccess?: () => void } @@ -46,12 +47,21 @@ export function DeleteCandidatesDialog({ }: DeleteCandidatesDialogProps) { const [isDeletePending, startDeleteTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session, status } = useSession() function onDelete() { startDeleteTransition(async () => { + + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + + const userId = Number(session.user.id) + const { error } = await removeCandidates({ ids: candidates.map((candidate) => candidate.id), - }) + }, userId) if (error) { toast.error(error) diff --git a/lib/vendor-candidates/table/excel-template-download.tsx b/lib/vendor-candidates/table/excel-template-download.tsx index b69ab821..673680db 100644 --- a/lib/vendor-candidates/table/excel-template-download.tsx +++ b/lib/vendor-candidates/table/excel-template-download.tsx @@ -16,10 +16,14 @@ export async function exportVendorCandidateTemplate() { // Define the columns with expected headers const columns = [ { header: "Company Name", key: "companyName", width: 30 }, + { header: "Tax ID", key: "taxId", width: 20 }, { header: "Contact Email", key: "contactEmail", width: 30 }, { header: "Contact Phone", key: "contactPhone", width: 20 }, + { header: "Address", key: "address", width: 40 }, { header: "Country", key: "country", width: 20 }, { header: "Source", key: "source", width: 20 }, + { header: "Items", key: "items", width: 40 }, + { header: "Remark", key: "remark", width: 40 }, { header: "Status", key: "status", width: 15 }, ] @@ -27,7 +31,7 @@ export async function exportVendorCandidateTemplate() { worksheet.columns = columns // Style the header row - const headerRow = worksheet.getRow(1) + const headerRow = worksheet.getRow(2) headerRow.font = { bold: true } headerRow.alignment = { horizontal: "center" } headerRow.eachCell((cell) => { @@ -36,24 +40,39 @@ export async function exportVendorCandidateTemplate() { pattern: "solid", fgColor: { argb: "FFCCCCCC" }, } + + // Mark required fields with a red asterisk + const requiredFields = ["Company Name", "Source", "Items"] + if (requiredFields.includes(cell.value as string)) { + cell.value = `${cell.value} *` + cell.font = { bold: true, color: { argb: "FFFF0000" } } + } }) // Add example data rows const exampleData = [ { companyName: "ABC Corporation", + taxId: "123-45-6789", contactEmail: "contact@abc.com", contactPhone: "+1-123-456-7890", + address: "123 Business Ave, Suite 100, New York, NY 10001", country: "US", source: "Website", + items: "Electronic components, Circuit boards, Sensors", + remark: "Potential supplier for Project X", status: "COLLECTED", }, { companyName: "XYZ Ltd.", + taxId: "GB987654321", contactEmail: "info@xyz.com", contactPhone: "+44-987-654-3210", + address: "45 Industrial Park, London, EC2A 4PX", country: "GB", source: "Referral", + items: "Steel components, Metal frames, Industrial hardware", + remark: "Met at trade show in March", status: "COLLECTED", }, ] @@ -65,8 +84,11 @@ export async function exportVendorCandidateTemplate() { // Add data validation for Status column const statusValues = ["COLLECTED", "INVITED", "DISCARDED"] - for (let i = 2; i <= 100; i++) { // Apply to rows 2-100 - worksheet.getCell(`F${i}`).dataValidation = { + const statusColumn = columns.findIndex(col => col.key === "status") + 1 + const statusColLetter = String.fromCharCode(64 + statusColumn) + + for (let i = 4; i <= 100; i++) { // Apply to rows 4-100 (after example data) + worksheet.getCell(`${statusColLetter}${i}`).dataValidation = { type: 'list', allowBlank: true, formulae: [`"${statusValues.join(',')}"`] @@ -74,11 +96,23 @@ export async function exportVendorCandidateTemplate() { } // Add instructions row - worksheet.insertRow(1, ["Please fill in the data below. Required fields: Company Name, Contact Email"]) - worksheet.mergeCells("A1:F1") + worksheet.insertRow(1, ["Please fill in the data below. Required fields are marked with an asterisk (*): Company Name, Source, Items"]) + worksheet.mergeCells(`A1:${String.fromCharCode(64 + columns.length)}1`) const instructionRow = worksheet.getRow(1) instructionRow.font = { bold: true, color: { argb: "FF0000FF" } } instructionRow.alignment = { horizontal: "center" } + instructionRow.height = 30 + + // Auto-width columns based on content + worksheet.columns.forEach(column => { + if (column.key) { // Check that column.key is defined + const dataMax = Math.max(...worksheet.getColumn(column.key).values + .filter(value => value !== null && value !== undefined) + .map(value => String(value).length) + ) + column.width = Math.max(column.width || 10, dataMax + 2) + } + }) // Download the workbook const buffer = await workbook.xlsx.writeBuffer() diff --git a/lib/vendor-candidates/table/import-button.tsx b/lib/vendor-candidates/table/import-button.tsx index 1a2a4f7c..b1dd43a9 100644 --- a/lib/vendor-candidates/table/import-button.tsx +++ b/lib/vendor-candidates/table/import-button.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { Upload, Loader } from 'lucide-react' import { createVendorCandidate } from '../service' import { Input } from '@/components/ui/input' +import { useSession } from "next-auth/react" // next-auth 세션 훅 추가 interface ImportExcelProps { onSuccess?: () => void @@ -15,24 +16,25 @@ interface ImportExcelProps { export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { const fileInputRef = useRef<HTMLInputElement>(null) const [isImporting, setIsImporting] = React.useState(false) + const { data: session, status } = useSession() // Helper function to get cell value as string const getCellValueAsString = (cell: ExcelJS.Cell): string => { if (!cell || cell.value === undefined || cell.value === null) return ''; - + if (typeof cell.value === 'string') return cell.value.trim(); if (typeof cell.value === 'number') return cell.value.toString(); - + // Handle rich text if (typeof cell.value === 'object' && 'richText' in cell.value) { return cell.value.richText.map((rt: any) => rt.text).join(''); } - + // Handle dates if (cell.value instanceof Date) { return cell.value.toISOString().split('T')[0]; } - + // Fallback return String(cell.value); } @@ -42,55 +44,55 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { if (!file) return setIsImporting(true) - + try { // Read the Excel file using ExcelJS const data = await file.arrayBuffer() const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(data) - + // Get the first worksheet const worksheet = workbook.getWorksheet(1) if (!worksheet) { toast.error("No worksheet found in the spreadsheet") return } - + // Check if there's an instruction row - const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null && - worksheet.getRow(1).getCell(2).value === null; - + const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null && + worksheet.getRow(1).getCell(2).value === null; + // Get header row index (row 2 if there's an instruction row, otherwise row 1) const headerRowIndex = hasInstructionRow ? 2 : 1; - + // Get column headers and their indices const headerRow = worksheet.getRow(headerRowIndex); const headers: Record<number, string> = {}; const columnIndices: Record<string, number> = {}; - + headerRow.eachCell((cell, colNumber) => { const header = getCellValueAsString(cell); headers[colNumber] = header; columnIndices[header] = colNumber; }); - + // Process data rows const rows: any[] = []; const startRow = headerRowIndex + 1; - + for (let i = startRow; i <= worksheet.rowCount; i++) { const row = worksheet.getRow(i); - + // Skip empty rows if (row.cellCount === 0) continue; - + // Check if this is likely an example row - const isExample = i === startRow && worksheet.getRow(i+1).values?.length === 0; + const isExample = i === startRow && worksheet.getRow(i + 1).values?.length === 0; if (isExample) continue; - + const rowData: Record<string, any> = {}; let hasData = false; - + // Map the data using header indices Object.entries(columnIndices).forEach(([header, colIndex]) => { const value = getCellValueAsString(row.getCell(colIndex)); @@ -99,22 +101,22 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { hasData = true; } }); - + if (hasData) { rows.push(rowData); } } - + if (rows.length === 0) { toast.error("No data found in the spreadsheet") setIsImporting(false) return } - + // Process each row let successCount = 0; let errorCount = 0; - + // Create promises for all vendor candidate creation operations const promises = rows.map(async (row) => { try { @@ -123,28 +125,40 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { companyName: String(row['Company Name'] || ''), contactEmail: String(row['Contact Email'] || ''), contactPhone: String(row['Contact Phone'] || ''), + taxId: String(row['Tax ID'] || ''), + address: String(row['Address'] || ''), country: String(row['Country'] || ''), source: String(row['Source'] || ''), + items: String(row['Items'] || ''), + remark: String(row['Remark'] || row['Remarks'] || ''), // Default to COLLECTED if not specified status: (row['Status'] || 'COLLECTED') as "COLLECTED" | "INVITED" | "DISCARDED" }; - + // Validate required fields - if (!candidateData.companyName || !candidateData.contactEmail) { + if (!candidateData.companyName || !candidateData.source || + !candidateData.items) { console.error("Missing required fields", candidateData); errorCount++; return null; } - + + if (!session || !session.user || !session.user.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + + const userId = session.user.id + // Create the vendor candidate - const result = await createVendorCandidate(candidateData); - + const result = await createVendorCandidate(candidateData, Number(userId)) + if (result.error) { console.error(`Failed to import row: ${result.error}`, candidateData); errorCount++; return null; } - + successCount++; return result.data; } catch (error) { @@ -153,10 +167,10 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { return null; } }); - + // Wait for all operations to complete await Promise.all(promises); - + // Show results if (successCount > 0) { toast.success(`Successfully imported ${successCount} vendor candidates`); @@ -168,7 +182,7 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { } else if (errorCount > 0) { toast.error(`Failed to import all ${errorCount} rows due to errors`); } - + } catch (error) { console.error("Import error:", error); toast.error("Error importing data. Please check file format."); diff --git a/lib/vendor-candidates/table/invite-candidates-dialog.tsx b/lib/vendor-candidates/table/invite-candidates-dialog.tsx index 366b6f45..45cf13c3 100644 --- a/lib/vendor-candidates/table/invite-candidates-dialog.tsx +++ b/lib/vendor-candidates/table/invite-candidates-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Row } from "@tanstack/react-table" -import { Loader, Mail } from "lucide-react" +import { Loader, Mail, AlertCircle, XCircle } from "lucide-react" import { toast } from "sonner" import { useMediaQuery } from "@/hooks/use-media-query" @@ -27,9 +27,15 @@ import { 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> { @@ -46,12 +52,35 @@ export function InviteCandidatesDialog({ }: 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 userId = Number(session.user.id) const { error } = await bulkUpdateVendorCandidateStatus({ - ids: candidates.map((candidate) => candidate.id), + ids: candidatesWithEmail.map((candidate) => candidate.id), status: "INVITED", + userId, + comment: "Bulk invite action", }) if (error) { @@ -60,11 +89,72 @@ export function InviteCandidatesDialog({ } props.onOpenChange?.(false) - toast.success("Invitation emails sent") + + 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}> @@ -79,12 +169,8 @@ export function InviteCandidatesDialog({ <DialogContent> <DialogHeader> <DialogTitle>Send invitations?</DialogTitle> - <DialogDescription> - This will send invitation emails to{" "} - <span className="font-medium">{candidates.length}</span> - {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED. - </DialogDescription> </DialogHeader> + {DialogComponent} <DialogFooter className="gap-2 sm:space-x-0"> <DialogClose asChild> <Button variant="outline">Cancel</Button> @@ -93,7 +179,7 @@ export function InviteCandidatesDialog({ aria-label="Invite selected vendors" variant="default" onClick={onInvite} - disabled={isInvitePending} + disabled={disableInviteButton} > {isInvitePending && ( <Loader @@ -122,12 +208,8 @@ export function InviteCandidatesDialog({ <DrawerContent> <DrawerHeader> <DrawerTitle>Send invitations?</DrawerTitle> - <DrawerDescription> - This will send invitation emails to{" "} - <span className="font-medium">{candidates.length}</span> - {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED. - </DrawerDescription> </DrawerHeader> + {DialogComponent} <DrawerFooter className="gap-2 sm:space-x-0"> <DrawerClose asChild> <Button variant="outline">Cancel</Button> @@ -136,7 +218,7 @@ export function InviteCandidatesDialog({ aria-label="Invite selected vendors" variant="default" onClick={onInvite} - disabled={isInvitePending} + disabled={disableInviteButton} > {isInvitePending && ( <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> diff --git a/lib/vendor-candidates/table/update-candidate-sheet.tsx b/lib/vendor-candidates/table/update-candidate-sheet.tsx index c475210b..3d278126 100644 --- a/lib/vendor-candidates/table/update-candidate-sheet.tsx +++ b/lib/vendor-candidates/table/update-candidate-sheet.tsx @@ -1,7 +1,6 @@ "use client" import * as React from "react" -import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors" import { zodResolver } from "@hookform/resolvers/zod" import { Check, ChevronsUpDown, Loader } from "lucide-react" import { useForm } from "react-hook-form" @@ -38,6 +37,7 @@ import { SheetTitle, } from "@/components/ui/sheet" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" import { Popover, PopoverContent, @@ -51,9 +51,11 @@ import { CommandItem, CommandList, } from "@/components/ui/command" +import { useSession } from "next-auth/react" // next-auth 세션 훅 import { updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "../validations" import { updateVendorCandidate } from "../service" +import { vendorCandidates,VendorCandidatesWithVendorInfo} from "@/db/schema" // Register locales for countries i18nIsoCountries.registerLocale(enLocale) @@ -69,47 +71,65 @@ const countryArray = Object.entries(countryMap).map(([code, label]) => ({ interface UpdateCandidateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - candidate: VendorCandidates | null + candidate: VendorCandidatesWithVendorInfo | null } export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateSheetProps) { const [isUpdatePending, startUpdateTransition] = React.useTransition() + const { data: session, status } = useSession() // Set default values from candidate data when the component receives a new candidate + React.useEffect(() => { if (candidate) { form.reset({ id: candidate.id, companyName: candidate.companyName, - contactEmail: candidate.contactEmail, + taxId: candidate.taxId, + contactEmail: candidate.contactEmail || "", // null을 빈 문자열로 변환 contactPhone: candidate.contactPhone || "", + address: candidate.address || "", country: candidate.country || "", source: candidate.source || "", + items: candidate.items, + remark: candidate.remark || "", status: candidate.status, }) } }, [candidate]) + const form = useForm<UpdateVendorCandidateSchema>({ resolver: zodResolver(updateVendorCandidateSchema), defaultValues: { id: candidate?.id || 0, companyName: candidate?.companyName || "", + taxId: candidate?.taxId || "", contactEmail: candidate?.contactEmail || "", contactPhone: candidate?.contactPhone || "", + address: candidate?.address || "", country: candidate?.country || "", source: candidate?.source || "", + items: candidate?.items || "", + remark: candidate?.remark || "", status: candidate?.status || "COLLECTED", }, }) function onSubmit(input: UpdateVendorCandidateSchema) { startUpdateTransition(async () => { + + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + if (!candidate) return const { error } = await updateVendorCandidate({ ...input, - }) + }, userId) if (error) { toast.error(error) @@ -124,7 +144,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe return ( <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetContent className="flex flex-col gap-6 sm:max-w-md overflow-y-auto"> <SheetHeader className="text-left"> <SheetTitle>Update Vendor Candidate</SheetTitle> <SheetDescription> @@ -142,7 +162,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe name="companyName" render={({ field }) => ( <FormItem> - <FormLabel>Company Name</FormLabel> + <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Enter company name" @@ -155,6 +175,25 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe )} /> + {/* Tax ID Field */} + <FormField + control={form.control} + name="taxId" + render={({ field }) => ( + <FormItem> + <FormLabel>Tax ID</FormLabel> + <FormControl> + <Input + placeholder="Enter tax ID" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Contact Email Field */} <FormField control={form.control} @@ -194,6 +233,25 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe )} /> + {/* Address Field */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem> + <FormLabel>Address</FormLabel> + <FormControl> + <Input + placeholder="Enter company address" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Country Field */} <FormField control={form.control} @@ -266,7 +324,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe name="source" render={({ field }) => ( <FormItem> - <FormLabel>Source</FormLabel> + <FormLabel>Source <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Where this candidate was found" @@ -279,6 +337,46 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe )} /> + {/* Items Field */} + <FormField + control={form.control} + name="items" + render={({ field }) => ( + <FormItem> + <FormLabel>Items <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Textarea + placeholder="List of items or products this vendor provides" + className="min-h-[80px]" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Remark Field */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem> + <FormLabel>Remark</FormLabel> + <FormControl> + <Textarea + placeholder="Additional notes or comments" + className="min-h-[80px]" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Status Field */} <FormField control={form.control} diff --git a/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx new file mode 100644 index 00000000..6d119bf3 --- /dev/null +++ b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx @@ -0,0 +1,246 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { ScrollArea } from "@/components/ui/scroll-area" +import { formatDateTime } from "@/lib/utils" +import { CandidateLogWithUser, getCandidateLogs } from "../service" +import { useToast } from "@/hooks/use-toast" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Download, Search, User } from "lucide-react" + +interface ViewCandidateLogsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + candidateId: number | null +} + +export function ViewCandidateLogsDialog({ + open, + onOpenChange, + candidateId, +}: ViewCandidateLogsDialogProps) { + const [logs, setLogs] = React.useState<CandidateLogWithUser[]>([]) + const [filteredLogs, setFilteredLogs] = React.useState<CandidateLogWithUser[]>([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState<string | null>(null) + const [searchQuery, setSearchQuery] = React.useState("") + const [actionFilter, setActionFilter] = React.useState<string>("all") + const { toast } = useToast() + + // Get unique action types for filter dropdown + const actionTypes = React.useMemo(() => { + if (!logs.length) return [] + return Array.from(new Set(logs.map(log => log.action))) + }, [logs]) + + // Fetch logs when dialog opens + React.useEffect(() => { + if (open && candidateId) { + setLoading(true) + setError(null) + getCandidateLogs(candidateId) + .then((res) => { + setLogs(res) + setFilteredLogs(res) + }) + .catch((err) => { + console.error(err) + setError("Failed to load logs. Please try again.") + toast({ + variant: "destructive", + title: "Error", + description: "Failed to load candidate logs", + }) + }) + .finally(() => setLoading(false)) + } else { + // Reset state when dialog closes + setSearchQuery("") + setActionFilter("all") + } + }, [open, candidateId, toast]) + + // Filter logs based on search query and action filter + React.useEffect(() => { + if (!logs.length) return + + let result = [...logs] + + // Apply action filter + if (actionFilter !== "all") { + result = result.filter(log => log.action === actionFilter) + } + + // Apply search filter (case insensitive) + if (searchQuery) { + const query = searchQuery.toLowerCase() + result = result.filter(log => + log.action.toLowerCase().includes(query) || + (log.comment && log.comment.toLowerCase().includes(query)) || + (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) || + (log.newStatus && log.newStatus.toLowerCase().includes(query)) || + (log.userName && log.userName.toLowerCase().includes(query)) || + (log.userEmail && log.userEmail.toLowerCase().includes(query)) + ) + } + + setFilteredLogs(result) + }, [logs, searchQuery, actionFilter]) + + // Export logs as CSV + const exportLogs = () => { + if (!filteredLogs.length) return + + const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"] + const csvContent = [ + headers.join(","), + ...filteredLogs.map(log => [ + `"${log.action}"`, + `"${log.oldStatus || ''}"`, + `"${log.newStatus || ''}"`, + `"${log.comment?.replace(/"/g, '""') || ''}"`, + `"${log.userName || ''}"`, + `"${log.userEmail || ''}"`, + `"${formatDateTime(log.createdAt)}"` + ].join(",")) + ].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.setAttribute("href", url) + link.setAttribute("download", `candidate-logs-${candidateId}-${new Date().toISOString().split('T')[0]}.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + // Render status change with appropriate badge + const renderStatusChange = (oldStatus: string, newStatus: string) => { + return ( + <div className="text-sm flex flex-wrap gap-2 items-center"> + <strong>Status:</strong> + <Badge variant="outline" className="text-xs">{oldStatus}</Badge> + <span>→</span> + <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge> + </div> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px]"> + <DialogHeader> + <DialogTitle>Audit Logs</DialogTitle> + </DialogHeader> + + {/* Filters and search */} + {/* Filters and search */} + <div className="flex items-center gap-2 mb-4"> + <div className="relative flex-1"> + <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none"> + <Search className="h-4 w-4 text-muted-foreground" /> + </div> + <Input + placeholder="Search logs..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + <Select + value={actionFilter} + onValueChange={setActionFilter} + > + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder="Filter by action" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All actions</SelectItem> + {actionTypes.map(action => ( + <SelectItem key={action} value={action}>{action}</SelectItem> + ))} + </SelectContent> + </Select> + + <Button + size="icon" + variant="outline" + onClick={exportLogs} + disabled={filteredLogs.length === 0} + title="Export to CSV" + > + <Download className="h-4 w-4" /> + </Button> + </div> + + <div className="space-y-2"> + {loading && ( + <div className="flex justify-center py-8"> + <p className="text-muted-foreground">Loading logs...</p> + </div> + )} + + {error && !loading && ( + <div className="bg-destructive/10 text-destructive p-3 rounded-md"> + {error} + </div> + )} + + {!loading && !error && filteredLogs.length === 0 && ( + <p className="text-muted-foreground text-center py-8"> + {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."} + </p> + )} + + {!loading && !error && filteredLogs.length > 0 && ( + <> + <div className="text-xs text-muted-foreground mb-2"> + Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'} + {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`} + </div> + <ScrollArea className="max-h-96 space-y-4 pr-4"> + {filteredLogs.map((log) => ( + <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors"> + <div className="flex justify-between items-start mb-2"> + <Badge className="text-xs">{log.action}</Badge> + <div className="text-xs text-muted-foreground"> + {formatDateTime(log.createdAt)} + </div> + </div> + + {log.oldStatus && log.newStatus && ( + <div className="my-2"> + {renderStatusChange(log.oldStatus, log.newStatus)} + </div> + )} + + {log.comment && ( + <div className="my-2 text-sm bg-muted/50 p-2 rounded-md"> + <strong>Comment:</strong> {log.comment} + </div> + )} + + {(log.userName || log.userEmail) && ( + <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground"> + <User className="h-3 w-3 mr-1" /> + {log.userName || "Unknown"} + {log.userEmail && <span className="ml-1">({log.userEmail})</span>} + </div> + )} + </div> + ))} + </ScrollArea> + </> + )} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
