diff options
Diffstat (limited to 'lib/rfq-last/vendor/rfq-vendor-table.tsx')
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 463 |
1 files changed, 410 insertions, 53 deletions
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index b2ea7588..830fd448 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -25,7 +25,9 @@ import { Package, MapPin, Info, - Loader2 + Loader2, + Router, + Shield } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -52,14 +54,18 @@ import { toast } from "sonner"; import { AddVendorDialog } from "./add-vendor-dialog"; import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; import { SendRfqDialog } from "./send-rfq-dialog"; -// import { VendorDetailDialog } from "./vendor-detail-dialog"; -// import { sendRfqToVendors } from "@/app/actions/rfq/send-rfq.action"; + import { getRfqSendData, getSelectedVendorsWithEmails, + sendRfqToVendors, type RfqSendData, type VendorEmailInfo } from "../service" +import { VendorResponseDetailDialog } from "./vendor-detail-dialog"; +import { DeleteVendorDialog } from "./delete-vendor-dialog"; +import { useRouter } from "next/navigation" +import { EditContractDialog } from "./edit-contract-dialog"; // 타입 정의 interface RfqDetail { @@ -91,20 +97,64 @@ interface RfqDetail { sparepartDescription?: string | null; updatedAt?: Date | null; updatedByUserName?: string | null; + emailSentAt: string | null; + emailSentTo: string | null; // JSON string + emailResentCount: number; + lastEmailSentAt: string | null; + emailStatus: string | null; } interface VendorResponse { id: number; - vendorId: number; - status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + rfqsLastId: number; + rfqLastDetailsId: number; responseVersion: number; isLatest: boolean; - submittedAt: Date | null; - totalAmount: number | null; - currency: string | null; - vendorDeliveryDate: Date | null; - quotedItemCount?: number; - attachmentCount?: number; + status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + vendor: { + id: number; + code: string | null; + name: string; + email: string; + }; + submission: { + submittedAt: Date | null; + submittedBy: string | null; + submittedByName: string | null; + }; + pricing: { + totalAmount: number | null; + currency: string | null; + vendorCurrency: string | null; + }; + vendorTerms: { + paymentTermsCode: string | null; + incotermsCode: string | null; + deliveryDate: Date | null; + contractDuration: string | null; + }; + additionalRequirements: { + firstArticle: { + required: boolean | null; + acceptance: boolean | null; + }; + sparePart: { + required: boolean | null; + acceptance: boolean | null; + }; + }; + counts: { + quotedItems: number; + attachments: number; + }; + remarks: { + general: string | null; + technical: string | null; + }; + timestamps: { + createdAt: string; + updatedAt: string; + }; } // Props 타입 정의 @@ -178,7 +228,7 @@ const mergeVendorData = ( ): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => { return rfqDetails.map(detail => { const response = vendorResponses.find( - r => r.vendorId === detail.vendorId && r.isLatest + r => r.vendor.id === detail.vendorId && r.isLatest ); return { ...detail, response, rfqCode }; }); @@ -208,6 +258,14 @@ export function RfqVendorTable({ const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false); const [isLoadingSendData, setIsLoadingSendData] = React.useState(false); + const [deleteVendorData, setDeleteVendorData] = React.useState<{ + detailId: number; + vendorId: number; + vendorName: string; + vendorCode?: string | null; + hasResponse?: boolean; + responseStatus?: string | null; + } | null>(null); const [sendDialogData, setSendDialogData] = React.useState<{ rfqInfo: RfqSendData['rfqInfo'] | null; @@ -219,12 +277,19 @@ export function RfqVendorTable({ selectedVendors: [], }); + const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null); + + + const router = useRouter() + // 데이터 병합 const mergedData = React.useMemo( () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), [rfqDetails, vendorResponses, rfqCode] ); + console.log(mergedData, "mergedData") + // 일괄 발송 핸들러 const handleBulkSend = React.useCallback(async () => { if (selectedRows.length === 0) { @@ -277,6 +342,11 @@ export function RfqVendorTable({ contactsByPosition: v.contactsByPosition || {}, primaryEmail: v.primaryEmail, currency: v.currency, + ndaYn: v.ndaYn, + generalGtcYn: v.generalGtcYn, + projectGtcYn: v.projectGtcYn, + agreementYn: v.agreementYn, + sendVersion: v.sendVersion })), }); @@ -297,25 +367,38 @@ export function RfqVendorTable({ vendorName: string; vendorCode?: string | null; vendorCountry?: string | null; - vendorEmail?: string | null; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails?: Array<{ email: string; name?: string }>; currency?: string | null; - additionalRecipients: string[]; + contractRequirements?: { + ndaYn: boolean; + generalGtcYn: boolean; + projectGtcYn: boolean; + agreementYn: boolean; + projectCode?: string; + }; + isResend: boolean; + sendVersion?: number; }>; attachments: number[]; message?: string; + generatedPdfs?: Array<{ // 타입 추가 + key: string; + buffer: number[]; + fileName: string; + }>; }) => { try { // 서버 액션 호출 - // const result = await sendRfqToVendors({ - // rfqId, - // rfqCode, - // vendors: data.vendors, - // attachmentIds: data.attachments, - // message: data.message, - // }); - - // 임시 성공 처리 - console.log("RFQ 발송 데이터:", data); + const result = await sendRfqToVendors({ + rfqId, + rfqCode, + vendors: data.vendors, + attachmentIds: data.attachments, + message: data.message, + generatedPdfs: data.generatedPdfs, + }); // 성공 후 처리 setSelectedRows([]); @@ -324,14 +407,23 @@ export function RfqVendorTable({ attachments: [], selectedVendors: [], }); + + // 기본계약 생성 결과 표시 + if (result.contractResults && result.contractResults.length > 0) { + const totalContracts = result.contractResults.reduce((acc, r) => acc + r.totalCreated, 0); + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`); + } else { + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); + } - toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); + // 페이지 새로고침 + router.refresh(); } catch (error) { console.error("RFQ 발송 실패:", error); toast.error("RFQ 발송에 실패했습니다."); throw error; } - }, [rfqId, rfqCode]); + }, [rfqId, rfqCode, router]); // 액션 처리 const handleAction = React.useCallback(async (action: string, vendor: any) => { @@ -344,7 +436,7 @@ export function RfqVendorTable({ // 개별 RFQ 발송 try { setIsLoadingSendData(true); - + const [rfqSendData, vendorEmailInfos] = await Promise.all([ getRfqSendData(rfqId), getSelectedVendorsWithEmails(rfqId, [vendor.vendorId]) @@ -369,6 +461,11 @@ export function RfqVendorTable({ contactsByPosition: v.contactsByPosition || {}, primaryEmail: v.primaryEmail, currency: v.currency, + ndaYn: v.ndaYn, + generalGtcYn: v.generalGtcYn, + projectGtcYn: v.projectGtcYn, + agreementYn: v.agreementYn, + sendVersion: v.sendVersion, })), }); @@ -385,10 +482,29 @@ export function RfqVendorTable({ toast.info("수정 기능은 준비중입니다."); break; + case "edit-contract": + // 기본계약 수정 + setEditContractVendor(vendor); + break; + case "delete": - if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { - toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); + // quotationStatus 체크 + const hasQuotation = !!vendor.quotationStatus; + + if (hasQuotation) { + // 견적서가 있으면 즉시 에러 토스트 표시 + toast.error("이미 발송된 벤더는 삭제할 수 없습니다."); + return; } + + // 삭제 다이얼로그 열기 + setDeleteVendorData({ + detailId: vendor.detailId, + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + hasQuotation: hasQuotation, + }); break; case "response-detail": @@ -486,12 +602,188 @@ export function RfqVendorTable({ }, size: 100, }, + { - accessorKey: "basicContract", - header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, - cell: ({ row }) => row.original.basicContract || "-", - size: 100, + accessorKey: "contractRequirements", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />, + cell: ({ row }) => { + const vendor = row.original; + const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; + + // 기본계약 상태 확인 + const requirements = []; + + // 필수 계약들 + if (vendor.agreementYn) { + requirements.push({ + name: "기술자료", + icon: <FileText className="h-3 w-3" />, + color: "text-blue-500" + }); + } + + if (vendor.ndaYn) { + requirements.push({ + name: "NDA", + icon: <Shield className="h-3 w-3" />, + color: "text-green-500" + }); + } + + // GTC (국외 업체만) + if (!isKorean) { + if (vendor.generalGtcYn || vendor.gtcType === "general") { + requirements.push({ + name: "General GTC", + icon: <Globe className="h-3 w-3" />, + color: "text-purple-500" + }); + } else if (vendor.projectGtcYn || vendor.gtcType === "project") { + requirements.push({ + name: "Project GTC", + icon: <Globe className="h-3 w-3" />, + color: "text-indigo-500" + }); + } + } + + if (requirements.length === 0) { + return <span className="text-xs text-muted-foreground">없음</span>; + } + + return ( + <div className="flex flex-wrap gap-1"> + {requirements.map((req, idx) => ( + <TooltipProvider key={idx}> + <Tooltip> + <TooltipTrigger asChild> + <Badge variant="outline" className="text-xs px-1.5 py-0"> + <span className={cn("mr-1", req.color)}> + {req.icon} + </span> + {req.name} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p className="text-xs"> + {req.name === "기술자료" && "기술자료 제공 동의서"} + {req.name === "NDA" && "비밀유지 계약서"} + {req.name === "General GTC" && "일반 거래 약관"} + {req.name === "Project GTC" && "프로젝트별 거래 약관"} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ))} + </div> + ); + }, + size: 150, }, + + { + accessorKey: "sendVersion", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="발송 회차" />, + cell: ({ row }) => { + const version = row.original.sendVersion; + + + return <span>{version}</span>; + }, + size: 80, + }, + { + accessorKey: "emailStatus", + header: "이메일 상태", + cell: ({ row }) => { + const response = row.original; + const emailSentAt = response?.emailSentAt; + const emailResentCount = response?.emailResentCount || 0; + const emailStatus = response?.emailStatus; + const status = response?.status; + + if (!emailSentAt) { + return ( + <Badge variant="outline" className="bg-gray-50"> + <Mail className="h-3 w-3 mr-1" /> + 미발송 + </Badge> + ); + } + + // 이메일 상태 표시 (failed인 경우 특별 처리) + const getEmailStatusBadge = () => { + if (emailStatus === "failed") { + return ( + <Badge variant="destructive"> + <XCircle className="h-3 w-3 mr-1" /> + 발송 실패 + </Badge> + ); + } + return ( + <Badge variant={status === "제출완료" ? "success" : "default"}> + {getStatusIcon(status || "")} + {status} + </Badge> + ); + }; + + // emailSentTo JSON 파싱 + let recipients = { to: [], cc: [], sentBy: "" }; + try { + if (response?.email?.emailSentTo) { + recipients = JSON.parse(response.email.emailSentTo); + } + } catch (e) { + console.error("Failed to parse emailSentTo", e); + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <div className="flex flex-col gap-1"> + {getEmailStatusBadge()} + {emailResentCount > 1 && ( + <Badge variant="secondary" className="text-xs"> + 재발송 {emailResentCount - 1}회 + </Badge> + )} + </div> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p>최초 발송: {format(new Date(emailSentAt), "yyyy-MM-dd HH:mm")}</p> + {response?.email?.lastEmailSentAt && ( + <p>최근 발송: {format(new Date(response.email.lastEmailSentAt), "yyyy-MM-dd HH:mm")}</p> + )} + {recipients.to.length > 0 && ( + <p>수신자: {recipients.to.join(", ")}</p> + )} + {recipients.cc.length > 0 && ( + <p>참조: {recipients.cc.join(", ")}</p> + )} + {recipients.sentBy && ( + <p>발신자: {recipients.sentBy}</p> + )} + {emailStatus === "failed" && ( + <p className="text-red-500 font-semibold">⚠️ 이메일 발송 실패</p> + )} + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }, + size: 120, + }, + // { + // accessorKey: "basicContract", + // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, + // cell: ({ row }) => row.original.basicContract || "-", + // size: 100, + // }, { accessorKey: "currency", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />, @@ -641,20 +933,23 @@ export function RfqVendorTable({ size: 120, }, { - accessorKey: "response.submittedAt", + accessorKey: "response.submission.submittedAt", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, cell: ({ row }) => { - const submittedAt = row.original.response?.submittedAt; + const participationRepliedAt = row.original.response?.attend?.participationRepliedAt; - if (!submittedAt) { - return <Badge variant="outline">미참여</Badge>; + if (!participationRepliedAt) { + return <Badge variant="outline">미응답</Badge>; } + + const participationStatus = row.original.response?.attend?.participationStatus; + return ( <div className="flex flex-col gap-0.5"> - <Badge variant="default" className="text-xs">참여</Badge> + <Badge variant="default" className="text-xs">{participationStatus}</Badge> <span className="text-xs text-muted-foreground"> - {format(new Date(submittedAt), "MM-dd")} + {format(new Date(participationRepliedAt), "yyyy-MM-dd")} </span> </div> ); @@ -665,7 +960,7 @@ export function RfqVendorTable({ id: "responseDetail", header: "회신상세", cell: ({ row }) => { - const hasResponse = !!row.original.response?.submittedAt; + const hasResponse = !!row.original.response?.submission?.submittedAt; if (!hasResponse) { return <span className="text-muted-foreground text-xs">-</span>; @@ -731,6 +1026,10 @@ export function RfqVendorTable({ cell: ({ row }) => { const vendor = row.original; const hasResponse = !!vendor.response; + const emailSentAt = vendor.response?.email?.emailSentAt; + const emailResentCount = vendor.response?.email?.emailResentCount || 0; + const hasQuotation = !!vendor.quotationStatus; + const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; return ( <DropdownMenu> @@ -747,8 +1046,33 @@ export function RfqVendorTable({ <Eye className="mr-2 h-4 w-4" /> 상세보기 </DropdownMenuItem> - {!hasResponse && ( - <DropdownMenuItem + + {/* 기본계약 수정 메뉴 추가 */} + <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}> + <FileText className="mr-2 h-4 w-4" /> + 기본계약 수정 + </DropdownMenuItem> + + {emailSentAt && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleAction("resend", vendor)} + disabled={isLoadingSendData} + > + <RefreshCw className="mr-2 h-4 w-4" /> + 이메일 재발송 + {emailResentCount > 0 && ( + <Badge variant="outline" className="ml-2 text-xs"> + {emailResentCount} + </Badge> + )} + </DropdownMenuItem> + </> + )} + + {!emailSentAt && ( + <DropdownMenuItem onClick={() => handleAction("send", vendor)} disabled={isLoadingSendData} > @@ -756,24 +1080,28 @@ export function RfqVendorTable({ RFQ 발송 </DropdownMenuItem> )} - <DropdownMenuItem onClick={() => handleAction("edit", vendor)}> - <Edit className="mr-2 h-4 w-4" /> - 조건 수정 - </DropdownMenuItem> + <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => handleAction("delete", vendor)} - className="text-red-600" + className={cn( + "text-red-600", + hasQuotation && "opacity-50 cursor-not-allowed" + )} + disabled={hasQuotation} > <Trash2 className="mr-2 h-4 w-4" /> 삭제 + {hasQuotation && ( + <span className="ml-2 text-xs">(불가)</span> + )} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }, size: 60, - }, + } ], [handleAction, rfqCode, isLoadingSendData]); const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ @@ -850,7 +1178,7 @@ export function RfqVendorTable({ ) : ( <> <Send className="h-4 w-4 mr-2" /> - 선택 발송 ({selectedRows.length}) + RFQ 발송 ({selectedRows.length}) </> )} </Button> @@ -924,14 +1252,43 @@ export function RfqVendorTable({ /> {/* 벤더 상세 다이얼로그 */} - {/* {selectedVendor && ( - <VendorDetailDialog + {selectedVendor && ( + <VendorResponseDetailDialog open={!!selectedVendor} onOpenChange={(open) => !open && setSelectedVendor(null)} - vendor={selectedVendor} + data={selectedVendor} + rfqId={rfqId} + /> + )} + + {/* 삭제 다이얼로그 추가 */} + {deleteVendorData && ( + <DeleteVendorDialog + open={!!deleteVendorData} + onOpenChange={(open) => !open && setDeleteVendorData(null)} rfqId={rfqId} + vendorData={deleteVendorData} + onSuccess={() => { + setDeleteVendorData(null); + router.refresh(); + // 데이터 새로고침 + }} /> - )} */} + )} + + {/* 기본계약 수정 다이얼로그 - 새로 추가 */} + {editContractVendor && ( + <EditContractDialog + open={!!editContractVendor} + onOpenChange={(open) => !open && setEditContractVendor(null)} + rfqId={rfqId} + vendor={editContractVendor} + onSuccess={() => { + setEditContractVendor(null); + router.refresh(); + }} + /> + )} </> ); }
\ No newline at end of file |
