summaryrefslogtreecommitdiff
path: root/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx')
-rw-r--r--lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx497
1 files changed, 497 insertions, 0 deletions
diff --git a/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx b/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx
new file mode 100644
index 00000000..cdbfaa0f
--- /dev/null
+++ b/lib/rfqs-ship/vendor-table/invite-vendors-dialog.tsx
@@ -0,0 +1,497 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Send, AlertTriangle, User } from "lucide-react"
+import { toast } from "sonner"
+import { z } from "zod"
+
+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, AlertDescription } from "@/components/ui/alert"
+
+import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig"
+import { inviteVendors, createCbeEvaluation } from "../service"
+import { RfqType } from "@/lib/rfqs-ship/validations"
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+
+// CBE 폼 스키마 정의
+const formSchema = z.object({
+ paymentTerms: z.string().min(1, "결제 조건을 입력하세요"),
+ incoterms: z.string().min(1, "Incoterms를 입력하세요"),
+ deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"),
+ notes: z.string().optional(),
+})
+
+type FormValues = z.infer<typeof formSchema>
+
+interface InviteVendorsDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<MatchedVendorRow>["original"][] | MatchedVendorRow[]
+ rfqId: number
+ rfqType: RfqType
+ showTrigger?: boolean
+ directCbe?: boolean
+ currentUser?: {
+ id: string
+ name?: string | null
+ email?: string | null
+ image?: string | null
+ companyId?: number | null
+ domain?: string | null
+ }
+ onSuccess?: () => void
+ children?: React.ReactNode
+}
+
+export function InviteVendorsDialog({
+ vendors,
+ rfqId,
+ rfqType,
+ showTrigger = true,
+ directCbe = false,
+ currentUser,
+ onSuccess,
+ children,
+ ...props
+}: InviteVendorsDialogProps) {
+ const [isInvitePending, startInviteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const [files, setFiles] = React.useState<FileList | null>(null)
+
+ // CBE 모드일 때 폼 상태 관리
+ const form = useForm<FormValues>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ paymentTerms: "",
+ incoterms: "",
+ deliverySchedule: "",
+ notes: "",
+ },
+ mode: "onChange",
+ })
+
+ // 폼 상태 감시
+ const { formState } = form
+ const isValid = formState.isValid &&
+ !!form.getValues("paymentTerms") &&
+ !!form.getValues("incoterms") &&
+ !!form.getValues("deliverySchedule")
+
+ // 기존 초대 함수
+ function onInvite() {
+ startInviteTransition(async () => {
+ const { error } = await inviteVendors({
+ rfqId,
+ vendorIds: vendors.map((vendor) => Number(vendor.id)),
+ rfqType
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Vendor invited")
+ onSuccess?.()
+ })
+ }
+
+ // CBE 요청 함수
+ async function onCbeRequest(data: FormValues) {
+ try {
+ startInviteTransition(async () => {
+ // 기본 FormData 생성
+ const formData = new FormData()
+
+ // rfqId 추가
+ formData.append("rfqId", String(rfqId))
+
+ // 폼 데이터 추가
+ Object.entries(data).forEach(([key, value]) => {
+ if (value !== undefined && value !== null) {
+ formData.append(key, String(value))
+ }
+ })
+
+ // 현재 사용자 ID 추가
+ if (currentUser?.id) {
+ formData.append("evaluatedBy", currentUser.id)
+ }
+
+ // 협력업체 ID만 추가
+ vendors.forEach((vendor) => {
+ formData.append("vendorIds[]", String(vendor.id))
+ })
+
+ // 파일 추가 (있는 경우에만)
+ if (files && files.length > 0) {
+ for (let i = 0; i < files.length; i++) {
+ formData.append("files", files[i])
+ }
+ }
+
+ // 서버 액션 호출
+ const response = await createCbeEvaluation(formData)
+
+ if (response.error) {
+ toast.error(response.error)
+ return
+ }
+
+ // 성공 처리
+ toast.success(`${vendors.length}개 협력업체에 CBE 요청이 성공적으로 전송되었습니다!`)
+ form.reset()
+ setFiles(null)
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ })
+ } catch (error) {
+ console.error(error)
+ toast.error("CBE 요청 생성 중 오류가 발생했습니다.")
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ setFiles(null)
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ // 필수 필드 라벨에 추가할 요소
+ const RequiredLabel = (
+ <span className="text-destructive ml-1 font-medium">*</span>
+ )
+
+ // CBE 모드일 때 폼 컨텐츠
+ const cbeFormContent = (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onCbeRequest)} className="space-y-4">
+ {/* 선택된 협력업체 정보 표시 */}
+ <div className="space-y-2">
+ <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel>
+ <ScrollArea className="h-20 border rounded-md p-2">
+ <div className="flex flex-wrap gap-2">
+ {vendors.map((vendor, index) => (
+ <Badge key={index} variant="secondary" className="py-1">
+ {vendor.vendorName || `협력업체 #${vendor.vendorCode}`}
+ </Badge>
+ ))}
+ </div>
+ </ScrollArea>
+ <FormDescription>
+ 선택된 모든 협력업체의 등록된 연락처에게 CBE 요청이 전송됩니다.
+ </FormDescription>
+ </div>
+
+ {/* 작성자 정보 (읽기 전용) */}
+ {currentUser && (
+ <div className="border rounded-md p-3 space-y-2">
+ <FormLabel>작성자</FormLabel>
+ <div className="flex items-center gap-3">
+ {currentUser.image ? (
+ <Avatar className="h-8 w-8">
+ <AvatarImage src={currentUser.image} alt={currentUser.name || ""} />
+ <AvatarFallback>
+ {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
+ </AvatarFallback>
+ </Avatar>
+ ) : (
+ <Avatar className="h-8 w-8">
+ <AvatarFallback>
+ {currentUser.name?.charAt(0) || <User className="h-4 w-4" />}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ <div>
+ <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p>
+ <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 결제 조건 - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="paymentTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 결제 조건{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: Net 30" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Incoterms - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="incoterms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ Incoterms{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: FOB, CIF" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 배송 일정 - 필수 필드 */}
+ <FormField
+ control={form.control}
+ name="deliverySchedule"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 배송 일정{RequiredLabel}
+ </FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="예: 계약 후 4주 이내" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 추가 참고사항 - 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="notes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>추가 참고사항</FormLabel>
+ <FormControl>
+ <Textarea
+ {...field}
+ placeholder="필요한 경우 추가 정보를 입력하세요"
+ className="min-h-[100px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 첨부 */}
+ <div className="space-y-2">
+ <FormLabel>첨부파일</FormLabel>
+ <Input
+ type="file"
+ multiple
+ onChange={(e) => setFiles(e.target.files)}
+ className="cursor-pointer"
+ />
+ <FormDescription>
+ CBE 요청에 첨부할 문서가 있다면 선택하세요.
+ </FormDescription>
+ </div>
+
+ <DialogFooter className="gap-2 sm:space-x-0 mt-6">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ type="submit"
+ variant="default"
+ disabled={!isValid || isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ CBE 요청 발송하기
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ )
+
+ // 기본 다이얼로그 컨텐츠
+ const defaultContent = (
+ <>
+ <DialogHeader>
+ <DialogTitle>벤더 초대 확인</DialogTitle>
+ <DialogDescription>
+ 선택한 <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 벤더" : "개 벤더"}에게 초대를 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 편집 제한 경고 메시지 */}
+ <Alert variant="destructive" className="mt-4">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다.
+ </AlertDescription>
+ </Alert>
+
+ <DialogFooter className="gap-2 sm:space-x-0 mt-6">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Invite selected rows"
+ variant="destructive"
+ onClick={onInvite}
+ disabled={isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 초대하기
+ </Button>
+ </DialogFooter>
+ </>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger && children ? (
+ <DialogTrigger asChild>
+ {children}
+ </DialogTrigger>
+ ) : showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ {directCbe ? "CBE 요청" : "초대하기"} ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent className={directCbe ? "sm:max-w-xl" : ""}>
+ {directCbe ? (
+ // CBE 직접 요청 모드
+ <>
+ <DialogHeader>
+ <DialogTitle>CBE 요청</DialogTitle>
+ <DialogDescription>
+ 선택한 협력업체({vendors.length}개)에게 CBE 요청을 발송합니다.
+ </DialogDescription>
+ </DialogHeader>
+ {cbeFormContent}
+ </>
+ ) : (
+ // 기존 초대 모드
+ defaultContent
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props} onOpenChange={handleDialogOpenChange}>
+ {showTrigger && children ? (
+ <DrawerTrigger asChild>
+ {children}
+ </DrawerTrigger>
+ ) : showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Send className="mr-2 size-4" aria-hidden="true" />
+ {directCbe ? "CBE 요청" : "초대하기"} ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ {directCbe ? (
+ // CBE 직접 요청 모드 (모바일)
+ <>
+ <DrawerHeader>
+ <DrawerTitle>CBE 요청</DrawerTitle>
+ <DrawerDescription>
+ 선택한 협력업체({vendors.length}개)에게 CBE 요청을 발송합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <div className="px-4 pb-4">
+ {cbeFormContent}
+ </div>
+ </>
+ ) : (
+ // 기존 초대 모드 (모바일)
+ <>
+ <DrawerHeader>
+ <DrawerTitle>벤더 초대 확인</DrawerTitle>
+ <DrawerDescription>
+ 선택한 <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 벤더" : "개 벤더"}에게 초대를 발송합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ {/* 편집 제한 경고 메시지 (모바일용) */}
+ <div className="px-4">
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription className="font-medium">
+ 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다.
+ </AlertDescription>
+ </Alert>
+ </div>
+
+ <DrawerFooter className="gap-2 sm:space-x-0 mt-4">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="초대하기"
+ variant="destructive"
+ onClick={onInvite}
+ disabled={isInvitePending}
+ >
+ {isInvitePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 초대하기
+ </Button>
+ </DrawerFooter>
+ </>
+ )}
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file