diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-08 10:29:19 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-08 10:29:19 +0000 |
| commit | f93493f68c9f368e10f1c3379f1c1384068e3b14 (patch) | |
| tree | a9dada58741750fa7ca6e04b210443ad99a6bccc /lib/rfq-last/vendor/send-rfq-dialog.tsx | |
| parent | e832a508e1b3c531fb3e1b9761e18e1b55e3d76a (diff) | |
(대표님, 최겸) rfqLast, bidding, prequote
Diffstat (limited to 'lib/rfq-last/vendor/send-rfq-dialog.tsx')
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 578 |
1 files changed, 578 insertions, 0 deletions
diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx new file mode 100644 index 00000000..dc420cad --- /dev/null +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -0,0 +1,578 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Send, + Building2, + User, + Calendar, + Package, + FileText, + Plus, + X, + Paperclip, + Download, + Mail, + Users, + AlertCircle, + Info, + File, + CheckCircle, + RefreshCw +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Alert, + AlertDescription, +} from "@/components/ui/alert"; + +// 타입 정의 +interface Vendor { + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + vendorEmail?: string | null; + currency?: string | null; +} + +interface Attachment { + id: number; + attachmentType: string; + serialNo: string; + currentRevision: string; + description?: string; + fileName?: string; + fileSize?: number; + uploadedAt?: Date; +} + +interface RfqInfo { + rfqCode: string; + rfqTitle: string; + rfqType: string; + projectCode?: string; + projectName?: string; + picName?: string; + picCode?: string; + picTeam?: string; + packageNo?: string; + packageName?: string; + designPicName?: string; + designTeam?: string; + materialGroup?: string; + materialGroupDesc?: string; + dueDate: Date; + quotationType?: string; + evaluationApply?: boolean; + contractType?: string; +} + +interface VendorWithRecipients extends Vendor { + additionalRecipients: string[]; +} + +interface SendRfqDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedVendors: Vendor[]; + rfqInfo: RfqInfo; + attachments?: Attachment[]; + onSend: (data: { + vendors: VendorWithRecipients[]; + attachments: number[]; + message?: string; + }) => Promise<void>; +} + +// 첨부파일 타입별 아이콘 +const getAttachmentIcon = (type: string) => { + switch (type.toLowerCase()) { + case "technical": + return <FileText className="h-4 w-4 text-blue-500" />; + case "commercial": + return <File className="h-4 w-4 text-green-500" />; + case "drawing": + return <Package className="h-4 w-4 text-purple-500" />; + default: + return <Paperclip className="h-4 w-4 text-gray-500" />; + } +}; + +// 파일 크기 포맷 +const formatFileSize = (bytes?: number) => { + if (!bytes) return "0 KB"; + const kb = bytes / 1024; + const mb = kb / 1024; + if (mb >= 1) return `${mb.toFixed(2)} MB`; + return `${kb.toFixed(2)} KB`; +}; + +export function SendRfqDialog({ + open, + onOpenChange, + selectedVendors, + rfqInfo, + attachments = [], + onSend, +}: SendRfqDialogProps) { + const [isSending, setIsSending] = React.useState(false); + const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]); + const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]); + const [additionalMessage, setAdditionalMessage] = React.useState(""); + + // 초기화 + React.useEffect(() => { + if (open && selectedVendors.length > 0) { + setVendorsWithRecipients( + selectedVendors.map(v => ({ + ...v, + additionalRecipients: [] + })) + ); + // 모든 첨부파일 선택 + setSelectedAttachments(attachments.map(a => a.id)); + } + }, [open, selectedVendors, attachments]); + + // 추가 수신처 이메일 추가 + const handleAddRecipient = (vendorId: number, email: string) => { + if (!email) return; + + // 이메일 유효성 검사 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + toast.error("올바른 이메일 형식이 아닙니다."); + return; + } + + setVendorsWithRecipients(prev => + prev.map(v => + v.vendorId === vendorId + ? { ...v, additionalRecipients: [...v.additionalRecipients, email] } + : v + ) + ); + }; + + // 추가 수신처 이메일 제거 + const handleRemoveRecipient = (vendorId: number, index: number) => { + setVendorsWithRecipients(prev => + prev.map(v => + v.vendorId === vendorId + ? { + ...v, + additionalRecipients: v.additionalRecipients.filter((_, i) => i !== index) + } + : v + ) + ); + }; + + // 첨부파일 선택 토글 + const toggleAttachment = (attachmentId: number) => { + setSelectedAttachments(prev => + prev.includes(attachmentId) + ? prev.filter(id => id !== attachmentId) + : [...prev, attachmentId] + ); + }; + + // 전송 처리 + const handleSend = async () => { + try { + setIsSending(true); + + // 유효성 검사 + if (selectedAttachments.length === 0) { + toast.warning("최소 하나 이상의 첨부파일을 선택해주세요."); + return; + } + + await onSend({ + vendors: vendorsWithRecipients, + attachments: selectedAttachments, + message: additionalMessage, + }); + + toast.success(`${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.`); + onOpenChange(false); + } catch (error) { + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + } finally { + setIsSending(false); + } + }; + + // 총 수신자 수 계산 + const totalRecipientCount = React.useMemo(() => { + return vendorsWithRecipients.reduce((acc, v) => + acc + 1 + v.additionalRecipients.length, 0 + ); + }, [vendorsWithRecipients]); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Send className="h-5 w-5" /> + RFQ 일괄 발송 + </DialogTitle> + <DialogDescription> + 선택한 {selectedVendors.length}개 업체에 RFQ를 발송합니다. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="flex-1 max-h-[calc(90vh-200px)]"> + <div className="space-y-6 pr-4"> + {/* RFQ 정보 섹션 */} + <div className="space-y-4"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Info className="h-4 w-4" /> + RFQ 정보 + </div> + + <div className="bg-muted/50 rounded-lg p-4 space-y-3"> + {/* 프로젝트 정보 */} + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">프로젝트:</span> + <span className="font-medium"> + {rfqInfo.projectCode || "PN003"} ({rfqInfo.projectName || "PETRONAS ZLNG nearshore project"}) + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">견적번호:</span> + <span className="font-medium font-mono">{rfqInfo.rfqCode}</span> + </div> + </div> + + {/* 담당자 정보 */} + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">구매담당:</span> + <span> + {rfqInfo.picName || "김*종"} ({rfqInfo.picCode || "86D"}) {rfqInfo.picTeam || "해양구매팀(해양구매1)"} + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">설계담당:</span> + <span> + {rfqInfo.designPicName || "이*진"} {rfqInfo.designTeam || "전장설계팀 (전장기기시스템)"} + </span> + </div> + </div> + + {/* PKG 및 자재 정보 */} + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">PKG 정보:</span> + <span> + {rfqInfo.packageNo || "MM03"} ({rfqInfo.packageName || "Deck Machinery"}) + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">자재그룹:</span> + <span> + {rfqInfo.materialGroup || "BE2101"} ({rfqInfo.materialGroupDesc || "Combined Windlass & Mooring Wi"}) + </span> + </div> + </div> + + {/* 견적 정보 */} + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">견적마감일:</span> + <span className="font-medium text-red-600"> + {format(rfqInfo.dueDate, "yyyy.MM.dd", { locale: ko })} + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">평가적용:</span> + <Badge variant={rfqInfo.evaluationApply ? "default" : "outline"}> + {rfqInfo.evaluationApply ? "Y" : "N"} + </Badge> + </div> + </div> + + {/* 견적명 */} + <div className="flex items-start gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">견적명:</span> + <span className="font-medium">{rfqInfo.rfqTitle}</span> + </div> + + {/* 계약구분 (일반견적일 때만) */} + {rfqInfo.rfqType === "일반견적" && ( + <div className="flex items-start gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">계약구분:</span> + <span>{rfqInfo.contractType || "-"}</span> + </div> + )} + </div> + </div> + + <Separator /> + + {/* 첨부파일 섹션 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Paperclip className="h-4 w-4" /> + 첨부파일 ({selectedAttachments.length}/{attachments.length}) + </div> + <Button + variant="ghost" + size="sm" + onClick={() => { + if (selectedAttachments.length === attachments.length) { + setSelectedAttachments([]); + } else { + setSelectedAttachments(attachments.map(a => a.id)); + } + }} + > + {selectedAttachments.length === attachments.length ? "전체 해제" : "전체 선택"} + </Button> + </div> + + <div className="border rounded-lg divide-y"> + {attachments.length > 0 ? ( + attachments.map((attachment) => ( + <div + key={attachment.id} + className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors" + > + <div className="flex items-center gap-3"> + <Checkbox + checked={selectedAttachments.includes(attachment.id)} + onCheckedChange={() => toggleAttachment(attachment.id)} + /> + {getAttachmentIcon(attachment.attachmentType)} + <div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium"> + {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} + </span> + <Badge variant="outline" className="text-xs"> + {attachment.currentRevision} + </Badge> + </div> + {attachment.description && ( + <p className="text-xs text-muted-foreground mt-0.5"> + {attachment.description} + </p> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground"> + {formatFileSize(attachment.fileSize)} + </span> + </div> + </div> + )) + ) : ( + <div className="p-8 text-center text-muted-foreground"> + <Paperclip className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">첨부파일이 없습니다.</p> + </div> + )} + </div> + </div> + + <Separator /> + + {/* 수신 업체 섹션 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Building2 className="h-4 w-4" /> + 수신 업체 ({selectedVendors.length}) + </div> + <Badge variant="outline" className="flex items-center gap-1"> + <Users className="h-3 w-3" /> + 총 {totalRecipientCount}명 + </Badge> + </div> + + <div className="space-y-3"> + {vendorsWithRecipients.map((vendor, index) => ( + <div + key={vendor.vendorId} + className="border rounded-lg p-4 space-y-3" + > + {/* 업체 정보 */} + <div className="flex items-start justify-between"> + <div className="flex items-center gap-3"> + <div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary text-sm font-medium"> + {index + 1} + </div> + <div> + <div className="flex items-center gap-2"> + <span className="font-medium">{vendor.vendorName}</span> + <Badge variant="outline" className="text-xs"> + {vendor.vendorCountry} + </Badge> + </div> + {vendor.vendorCode && ( + <span className="text-xs text-muted-foreground"> + {vendor.vendorCode} + </span> + )} + </div> + </div> + <Badge variant="secondary"> + 주 수신: {vendor.vendorEmail || "vendor@example.com"} + </Badge> + </div> + + {/* 추가 수신처 */} + <div className="pl-11 space-y-2"> + <div className="flex items-center gap-2"> + <Label className="text-xs text-muted-foreground">추가 수신처:</Label> + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <AlertCircle className="h-3 w-3 text-muted-foreground" /> + </TooltipTrigger> + <TooltipContent> + <p>참조로 RFQ를 받을 추가 이메일 주소를 입력하세요.</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + + {/* 추가된 이메일 목록 */} + <div className="flex flex-wrap gap-2"> + {vendor.additionalRecipients.map((email, idx) => ( + <Badge + key={idx} + variant="outline" + className="flex items-center gap-1 pr-1" + > + <Mail className="h-3 w-3" /> + {email} + <Button + variant="ghost" + size="sm" + className="h-4 w-4 p-0 hover:bg-transparent" + onClick={() => handleRemoveRecipient(vendor.vendorId, idx)} + > + <X className="h-3 w-3" /> + </Button> + </Badge> + ))} + </div> + + {/* 이메일 입력 필드 */} + <div className="flex gap-2"> + <Input + type="email" + placeholder="추가 수신자 이메일 입력" + className="h-8 text-sm" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + handleAddRecipient(vendor.vendorId, input.value); + input.value = ""; + } + }} + /> + <Button + variant="outline" + size="sm" + onClick={(e) => { + const input = (e.currentTarget.previousElementSibling as HTMLInputElement); + handleAddRecipient(vendor.vendorId, input.value); + input.value = ""; + }} + > + <Plus className="h-4 w-4" /> + </Button> + </div> + </div> + </div> + ))} + </div> + </div> + + <Separator /> + + {/* 추가 메시지 (선택사항) */} + <div className="space-y-2"> + <Label htmlFor="message" className="text-sm font-medium"> + 추가 메시지 (선택사항) + </Label> + <textarea + id="message" + className="w-full min-h-[80px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="업체에 전달할 추가 메시지를 입력하세요..." + value={additionalMessage} + onChange={(e) => setAdditionalMessage(e.target.value)} + /> + </div> + </div> + </ScrollArea> + + <DialogFooter className="flex-shrink-0"> + <Alert className="mr-auto max-w-md"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription className="text-xs"> + 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. + </AlertDescription> + </Alert> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSending} + > + 취소 + </Button> + <Button + onClick={handleSend} + disabled={isSending || selectedAttachments.length === 0} + > + {isSending ? ( + <> + <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> + 발송중... + </> + ) : ( + <> + <Send className="h-4 w-4 mr-2" /> + RFQ 발송 + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
