summaryrefslogtreecommitdiff
path: root/lib/rfq-last/vendor/send-rfq-dialog.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-08 10:29:19 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-08 10:29:19 +0000
commitf93493f68c9f368e10f1c3379f1c1384068e3b14 (patch)
treea9dada58741750fa7ca6e04b210443ad99a6bccc /lib/rfq-last/vendor/send-rfq-dialog.tsx
parente832a508e1b3c531fb3e1b9761e18e1b55e3d76a (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.tsx578
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