diff options
Diffstat (limited to 'lib/rfq-last/vendor')
| -rw-r--r-- | lib/rfq-last/vendor/batch-update-conditions-dialog.tsx | 81 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 208 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 578 |
3 files changed, 788 insertions, 79 deletions
diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx index 1b8fa528..7de8cfa4 100644 --- a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -50,11 +50,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; -import { +import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, - getPlaceOfDestinationForSelection + getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service"; interface BatchUpdateConditionsDialogProps { @@ -108,19 +108,19 @@ export function BatchUpdateConditionsDialog({ onSuccess, }: BatchUpdateConditionsDialogProps) { const [isLoading, setIsLoading] = React.useState(false); - + // Select 옵션들 상태 const [incoterms, setIncoterms] = React.useState<SelectOption[]>([]); const [paymentTerms, setPaymentTerms] = React.useState<SelectOption[]>([]); const [shippingPlaces, setShippingPlaces] = React.useState<SelectOption[]>([]); const [destinationPlaces, setDestinationPlaces] = React.useState<SelectOption[]>([]); - + // 로딩 상태 const [incotermsLoading, setIncotermsLoading] = React.useState(false); const [paymentTermsLoading, setPaymentTermsLoading] = React.useState(false); const [shippingLoading, setShippingLoading] = React.useState(false); const [destinationLoading, setDestinationLoading] = React.useState(false); - + // Popover 열림 상태 const [incotermsOpen, setIncotermsOpen] = React.useState(false); const [paymentTermsOpen, setPaymentTermsOpen] = React.useState(false); @@ -254,7 +254,7 @@ export function BatchUpdateConditionsDialog({ // 선택된 필드만 포함하여 conditions 객체 생성 const conditions: any = {}; - + if (fieldsToUpdate.currency && data.currency) { conditions.currency = data.currency; } @@ -372,7 +372,7 @@ export function BatchUpdateConditionsDialog({ <Alert> <Info className="h-4 w-4" /> <AlertDescription> - 체크박스를 선택한 항목만 업데이트됩니다. + 체크박스를 선택한 항목만 업데이트됩니다. 선택하지 않은 항목은 기존 값이 유지됩니다. </AlertDescription> </Alert> @@ -387,7 +387,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.currency} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked }) } /> @@ -419,7 +419,13 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="통화 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {currencies.map((currency) => ( @@ -454,7 +460,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.paymentTermsCode} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked }) } /> @@ -496,7 +502,13 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="코드 또는 설명으로 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {paymentTerms.map((term) => ( @@ -538,7 +550,7 @@ export function BatchUpdateConditionsDialog({ <Checkbox className="mt-3" checked={fieldsToUpdate.incoterms} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked }) } /> @@ -581,7 +593,12 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="코드 또는 설명으로 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }}> <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {incoterms.map((incoterm) => ( @@ -640,7 +657,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.deliveryDate} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked }) } /> @@ -701,7 +718,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.contractDuration} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked }) } /> @@ -736,7 +753,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.taxCode} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked }) } /> @@ -770,7 +787,7 @@ export function BatchUpdateConditionsDialog({ <Checkbox className="mt-3" checked={fieldsToUpdate.shipping} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked }) } /> @@ -813,7 +830,13 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="선적지 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {shippingPlaces.map((place) => ( @@ -848,7 +871,7 @@ export function BatchUpdateConditionsDialog({ </FormItem> )} /> - + <FormField control={form.control} name="placeOfDestination" @@ -887,7 +910,13 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="도착지 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {destinationPlaces.map((place) => ( @@ -937,7 +966,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.materialPrice} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked }) } /> @@ -973,7 +1002,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.sparepart} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked }) } /> @@ -1028,7 +1057,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.first} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, first: !!checked }) } /> @@ -1086,7 +1115,7 @@ export function BatchUpdateConditionsDialog({ <DialogFooter className="p-6 pt-4 border-t"> <div className="flex items-center justify-between w-full"> <div className="text-sm text-muted-foreground"> - {getUpdateCount() > 0 + {getUpdateCount() > 0 ? `${getUpdateCount()}개 항목 선택됨` : '변경할 항목을 선택하세요' } @@ -1100,12 +1129,12 @@ export function BatchUpdateConditionsDialog({ > 취소 </Button> - <Button + <Button type="submit" disabled={isLoading || getUpdateCount() === 0} > {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {getUpdateCount() > 0 + {getUpdateCount() > 0 ? `${getUpdateCount()}개 항목 업데이트` : '조건 업데이트' } diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index b6d42804..7f7afe14 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { +import { Plus, Send, Eye, @@ -32,7 +32,7 @@ import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; import { ClientDataTable } from "@/components/client-data-table/data-table"; -import { +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -50,7 +50,9 @@ import { cn } from "@/lib/utils"; 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"; // 타입 정의 interface RfqDetail { @@ -59,9 +61,10 @@ interface RfqDetail { vendorName: string | null; vendorCode: string | null; vendorCountry: string | null; - vendorCategory?: string | null; // 업체분류 - vendorGrade?: string | null; // AVL 등급 - basicContract?: string | null; // 기본계약 + vendorEmail?: string | null; + vendorCategory?: string | null; + vendorGrade?: string | null; + basicContract?: string | null; shortList: boolean; currency: string | null; paymentTermsCode: string | null; @@ -97,11 +100,42 @@ interface VendorResponse { attachmentCount?: number; } +// Props 타입 정의 (중복 제거하고 하나로 통합) interface RfqVendorTableProps { rfqId: number; rfqCode?: string; rfqDetails: RfqDetail[]; vendorResponses: VendorResponse[]; + // 추가 props + rfqInfo?: { + 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; + }; + attachments?: Array<{ + id: number; + attachmentType: string; + serialNo: string; + currentRevision: string; + description?: string; + fileName?: string; + fileSize?: number; + uploadedAt?: Date; + }>; } // 상태별 아이콘 반환 @@ -158,43 +192,94 @@ export function RfqVendorTable({ rfqCode, rfqDetails, vendorResponses, + rfqInfo, + attachments, }: RfqVendorTableProps) { const [isRefreshing, setIsRefreshing] = React.useState(false); const [selectedRows, setSelectedRows] = React.useState<any[]>([]); const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false); const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false); const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); + const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false); // 데이터 병합 const mergedData = React.useMemo( () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), [rfqDetails, vendorResponses, rfqCode] ); - + + // 일괄 발송 핸들러 + const handleBulkSend = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요."); + return; + } + + // 다이얼로그 열기 + setIsSendDialogOpen(true); + }, [selectedRows]); + + // RFQ 발송 핸들러 + const handleSendRfq = React.useCallback(async (data: { + vendors: Array<{ + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + vendorEmail?: string | null; + currency?: string | null; + additionalRecipients: string[]; + }>; + attachments: number[]; + message?: string; + }) => { + try { + // 서버 액션 호출 + // const result = await sendRfqToVendors({ + // rfqId, + // rfqCode, + // vendors: data.vendors, + // attachmentIds: data.attachments, + // message: data.message, + // }); + + // 임시 성공 처리 + console.log("RFQ 발송 데이터:", data); + + // 성공 후 처리 + setSelectedRows([]); + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); + } catch (error) { + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + throw error; + } + }, [rfqId, rfqCode]); + // 액션 처리 const handleAction = React.useCallback(async (action: string, vendor: any) => { switch (action) { case "view": setSelectedVendor(vendor); break; - + case "send": // RFQ 발송 로직 toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`); break; - + case "edit": // 수정 로직 toast.info("수정 기능은 준비중입니다."); break; - + case "delete": // 삭제 로직 if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); } break; - + case "response-detail": // 회신 상세 보기 toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); @@ -202,21 +287,6 @@ export function RfqVendorTable({ } }, []); - // 선택된 벤더들에게 일괄 발송 - const handleBulkSend = React.useCallback(async () => { - if (selectedRows.length === 0) { - toast.warning("발송할 벤더를 선택해주세요."); - return; - } - - const vendorNames = selectedRows.map(r => r.vendorName).join(", "); - if (confirm(`선택한 ${selectedRows.length}개 벤더에게 RFQ를 발송하시겠습니까?\n\n${vendorNames}`)) { - toast.success(`${selectedRows.length}개 벤더에게 RFQ를 발송했습니다.`); - setSelectedRows([]); - } - }, [selectedRows]); - - // 컬럼 정의 (확장된 버전) const columns: ColumnDef<any>[] = React.useMemo(() => [ { @@ -251,19 +321,6 @@ export function RfqVendorTable({ }, size: 120, }, - // { - // accessorKey: "response.responseVersion", - // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Rev" />, - // cell: ({ row }) => { - // const version = row.original.response?.responseVersion; - // return version ? ( - // <Badge variant="outline" className="font-mono">v{version}</Badge> - // ) : ( - // <span className="text-muted-foreground">-</span> - // ); - // }, - // size: 60, - // }, { accessorKey: "vendorName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />, @@ -307,14 +364,14 @@ export function RfqVendorTable({ cell: ({ row }) => { const grade = row.original.vendorGrade; if (!grade) return <span className="text-muted-foreground">-</span>; - + const gradeColor = { "A": "text-green-600", - "B": "text-blue-600", + "B": "text-blue-600", "C": "text-yellow-600", "D": "text-red-600", }[grade] || "text-gray-600"; - + return <span className={cn("font-semibold", gradeColor)}>{grade}</span>; }, size: 100, @@ -373,15 +430,15 @@ export function RfqVendorTable({ cell: ({ row }) => { const deliveryDate = row.original.deliveryDate; const contractDuration = row.original.contractDuration; - + return ( <div className="flex flex-col gap-0.5"> - {deliveryDate && ( + {deliveryDate && !rfqCode?.startsWith("F") && ( <span className="text-xs"> {format(new Date(deliveryDate), "yyyy-MM-dd")} </span> )} - {contractDuration && ( + {contractDuration && rfqCode?.startsWith("F") && ( <span className="text-xs text-muted-foreground">{contractDuration}</span> )} {!deliveryDate && !contractDuration && ( @@ -398,7 +455,7 @@ export function RfqVendorTable({ cell: ({ row }) => { const code = row.original.incotermsCode; const detail = row.original.incotermsDetail; - + return ( <TooltipProvider> <Tooltip> @@ -459,7 +516,7 @@ export function RfqVendorTable({ if (conditions === "-") { return <span className="text-muted-foreground">-</span>; } - + const items = conditions.split(", "); return ( <div className="flex flex-wrap gap-1"> @@ -479,11 +536,11 @@ export function RfqVendorTable({ cell: ({ row }) => { const submittedAt = row.original.response?.submittedAt; const status = row.original.response?.status; - + if (!submittedAt) { return <Badge variant="outline">미참여</Badge>; } - + return ( <div className="flex flex-col gap-0.5"> <Badge variant="default" className="text-xs">참여</Badge> @@ -500,11 +557,11 @@ export function RfqVendorTable({ header: "회신상세", cell: ({ row }) => { const hasResponse = !!row.original.response?.submittedAt; - + if (!hasResponse) { return <span className="text-muted-foreground text-xs">-</span>; } - + return ( <Button variant="ghost" @@ -565,7 +622,7 @@ export function RfqVendorTable({ cell: ({ row }) => { const vendor = row.original; const hasResponse = !!vendor.response; - + return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -592,7 +649,7 @@ export function RfqVendorTable({ 조건 수정 </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem + <DropdownMenuItem onClick={() => handleAction("delete", vendor)} className="text-red-600" > @@ -605,7 +662,7 @@ export function RfqVendorTable({ }, size: 60, }, - ], [handleAction]); + ], [handleAction, rfqCode]); const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ { id: "vendorName", label: "벤더명", type: "text" }, @@ -644,6 +701,41 @@ export function RfqVendorTable({ })); }, [selectedRows]); + // 선택된 벤더 정보 (Send용) + const selectedVendorsForSend = React.useMemo(() => { + return selectedRows.map(row => ({ + vendorId: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + vendorCountry: row.vendorCountry, + vendorEmail: row.vendorEmail || `vendor${row.vendorId}@example.com`, + currency: row.currency, + })); + }, [selectedRows]); + + // RFQ 정보 준비 (다이얼로그용) + const rfqInfoForDialog = React.useMemo(() => { + // props로 받은 rfqInfo 사용, 없으면 기본값 + return rfqInfo || { + rfqCode: rfqCode || '', + rfqTitle: '테스트 RFQ', + rfqType: '정기견적', + projectCode: 'PN003', + projectName: 'PETRONAS ZLNG nearshore project', + picName: '김*종', + picCode: '86D', + picTeam: '해양구매팀(해양구매1)', + packageNo: 'MM03', + packageName: 'Deck Machinery', + designPicName: '이*진', + designTeam: '전장설계팀 (전장기기시스템)', + materialGroup: 'BE2101', + materialGroupDesc: 'Combined Windlass & Mooring Wi', + dueDate: new Date('2025-07-05'), + evaluationApply: true, + }; + }, [rfqInfo, rfqCode]); + // 추가 액션 버튼들 const additionalActions = React.useMemo(() => ( <div className="flex items-center gap-2"> @@ -732,6 +824,16 @@ export function RfqVendorTable({ }} /> + {/* RFQ 발송 다이얼로그 */} + <SendRfqDialog + open={isSendDialogOpen} + onOpenChange={setIsSendDialogOpen} + selectedVendors={selectedVendorsForSend} + rfqInfo={rfqInfoForDialog} + attachments={attachments || []} + onSend={handleSendRfq} + /> + {/* 벤더 상세 다이얼로그 */} {/* {selectedVendor && ( <VendorDetailDialog 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 |
