diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-09 10:32:34 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-09 10:32:34 +0000 |
| commit | c62ec046327fd388ebce04571b55910747e69a3b (patch) | |
| tree | 41ccdc4a8dea99808622f6d5d52014ac59a2d7ab /lib/rfq-last/vendor | |
| parent | ebcec3f296d1d27943caf8a3aed26efef117cdc5 (diff) | |
(정희성, 최겸, 대표님) formatDate 변경 등
Diffstat (limited to 'lib/rfq-last/vendor')
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 205 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 803 |
2 files changed, 716 insertions, 292 deletions
diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 7f7afe14..b2ea7588 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -24,7 +24,8 @@ import { Globe, Package, MapPin, - Info + Info, + Loader2 } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -53,6 +54,12 @@ 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, + type RfqSendData, + type VendorEmailInfo +} from "../service" // 타입 정의 interface RfqDetail { @@ -100,13 +107,12 @@ interface VendorResponse { attachmentCount?: number; } -// Props 타입 정의 (중복 제거하고 하나로 통합) +// Props 타입 정의 interface RfqVendorTableProps { rfqId: number; rfqCode?: string; rfqDetails: RfqDetail[]; vendorResponses: VendorResponse[]; - // 추가 props rfqInfo?: { rfqTitle: string; rfqType: string; @@ -201,6 +207,17 @@ export function RfqVendorTable({ const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false); const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false); + const [isLoadingSendData, setIsLoadingSendData] = React.useState(false); + + const [sendDialogData, setSendDialogData] = React.useState<{ + rfqInfo: RfqSendData['rfqInfo'] | null; + attachments: RfqSendData['attachments']; + selectedVendors: VendorEmailInfo[]; + }>({ + rfqInfo: null, + attachments: [], + selectedVendors: [], + }); // 데이터 병합 const mergedData = React.useMemo( @@ -215,9 +232,63 @@ export function RfqVendorTable({ return; } - // 다이얼로그 열기 - setIsSendDialogOpen(true); - }, [selectedRows]); + try { + setIsLoadingSendData(true); + + // 선택된 벤더 ID들 추출 + const selectedVendorIds = selectedRows + .map(row => row.vendorId) + .filter(id => id != null); + + if (selectedVendorIds.length === 0) { + toast.error("유효한 벤더가 선택되지 않았습니다."); + return; + } + + // 병렬로 데이터 가져오기 (에러 처리 포함) + const [rfqSendData, vendorEmailInfos] = await Promise.all([ + getRfqSendData(rfqId), + getSelectedVendorsWithEmails(rfqId, selectedVendorIds) + ]); + + // 데이터 검증 + if (!rfqSendData?.rfqInfo) { + toast.error("RFQ 정보를 불러올 수 없습니다."); + return; + } + + if (!vendorEmailInfos || vendorEmailInfos.length === 0) { + toast.error("선택된 벤더의 이메일 정보를 찾을 수 없습니다."); + return; + } + + // 다이얼로그 데이터 설정 + setSendDialogData({ + rfqInfo: rfqSendData.rfqInfo, + attachments: rfqSendData.attachments || [], + selectedVendors: vendorEmailInfos.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + vendorEmail: v.vendorEmail, + representativeEmail: v.representativeEmail, + contacts: v.contacts || [], + contactsByPosition: v.contactsByPosition || {}, + primaryEmail: v.primaryEmail, + currency: v.currency, + })), + }); + + // 다이얼로그 열기 + setIsSendDialogOpen(true); + } catch (error) { + console.error("RFQ 발송 데이터 로드 실패:", error); + toast.error("데이터를 불러오는데 실패했습니다. 다시 시도해주세요."); + } finally { + setIsLoadingSendData(false); + } + }, [selectedRows, rfqId]); // RFQ 발송 핸들러 const handleSendRfq = React.useCallback(async (data: { @@ -248,6 +319,12 @@ export function RfqVendorTable({ // 성공 후 처리 setSelectedRows([]); + setSendDialogData({ + rfqInfo: null, + attachments: [], + selectedVendors: [], + }); + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); } catch (error) { console.error("RFQ 발송 실패:", error); @@ -264,30 +341,63 @@ export function RfqVendorTable({ break; case "send": - // RFQ 발송 로직 - toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`); + // 개별 RFQ 발송 + try { + setIsLoadingSendData(true); + + const [rfqSendData, vendorEmailInfos] = await Promise.all([ + getRfqSendData(rfqId), + getSelectedVendorsWithEmails(rfqId, [vendor.vendorId]) + ]); + + if (!rfqSendData?.rfqInfo || !vendorEmailInfos || vendorEmailInfos.length === 0) { + toast.error("벤더 정보를 불러올 수 없습니다."); + return; + } + + setSendDialogData({ + rfqInfo: rfqSendData.rfqInfo, + attachments: rfqSendData.attachments || [], + selectedVendors: vendorEmailInfos.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + vendorEmail: v.vendorEmail, + representativeEmail: v.representativeEmail, + contacts: v.contacts || [], + contactsByPosition: v.contactsByPosition || {}, + primaryEmail: v.primaryEmail, + currency: v.currency, + })), + }); + + setIsSendDialogOpen(true); + } catch (error) { + console.error("개별 발송 데이터 로드 실패:", error); + toast.error("데이터를 불러오는데 실패했습니다."); + } finally { + setIsLoadingSendData(false); + } break; case "edit": - // 수정 로직 toast.info("수정 기능은 준비중입니다."); break; case "delete": - // 삭제 로직 if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); } break; case "response-detail": - // 회신 상세 보기 toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); break; } - }, []); + }, [rfqId]); - // 컬럼 정의 (확장된 버전) + // 컬럼 정의 const columns: ColumnDef<any>[] = React.useMemo(() => [ { id: "select", @@ -535,7 +645,6 @@ export function RfqVendorTable({ header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, cell: ({ row }) => { const submittedAt = row.original.response?.submittedAt; - const status = row.original.response?.status; if (!submittedAt) { return <Badge variant="outline">미참여</Badge>; @@ -639,7 +748,10 @@ export function RfqVendorTable({ 상세보기 </DropdownMenuItem> {!hasResponse && ( - <DropdownMenuItem onClick={() => handleAction("send", vendor)}> + <DropdownMenuItem + onClick={() => handleAction("send", vendor)} + disabled={isLoadingSendData} + > <Send className="mr-2 h-4 w-4" /> RFQ 발송 </DropdownMenuItem> @@ -662,7 +774,7 @@ export function RfqVendorTable({ }, size: 60, }, - ], [handleAction, rfqCode]); + ], [handleAction, rfqCode, isLoadingSendData]); const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ { id: "vendorName", label: "벤더명", type: "text" }, @@ -701,41 +813,6 @@ 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"> @@ -743,6 +820,7 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={() => setIsAddDialogOpen(true)} + disabled={isLoadingSendData} > <Plus className="h-4 w-4 mr-2" /> 벤더 추가 @@ -753,6 +831,7 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={() => setIsBatchUpdateOpen(true)} + disabled={isLoadingSendData} > <Settings2 className="h-4 w-4 mr-2" /> 정보 일괄 입력 ({selectedRows.length}) @@ -761,9 +840,19 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={handleBulkSend} + disabled={isLoadingSendData || selectedRows.length === 0} > - <Send className="h-4 w-4 mr-2" /> - 선택 발송 ({selectedRows.length}) + {isLoadingSendData ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 데이터 준비중... + </> + ) : ( + <> + <Send className="h-4 w-4 mr-2" /> + 선택 발송 ({selectedRows.length}) + </> + )} </Button> </> )} @@ -777,13 +866,13 @@ export function RfqVendorTable({ toast.success("데이터를 새로고침했습니다."); }, 1000); }} - disabled={isRefreshing} + disabled={isRefreshing || isLoadingSendData} > <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> 새로고침 </Button> </div> - ), [selectedRows, isRefreshing, handleBulkSend]); + ), [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend]); return ( <> @@ -828,9 +917,9 @@ export function RfqVendorTable({ <SendRfqDialog open={isSendDialogOpen} onOpenChange={setIsSendDialogOpen} - selectedVendors={selectedVendorsForSend} - rfqInfo={rfqInfoForDialog} - attachments={attachments || []} + selectedVendors={sendDialogData.selectedVendors} + rfqInfo={sendDialogData.rfqInfo} + attachments={sendDialogData.attachments || []} onSend={handleSendRfq} /> diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index dc420cad..9d88bdc9 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -14,8 +14,8 @@ 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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Send, Building2, @@ -33,12 +33,18 @@ import { Info, File, CheckCircle, - RefreshCw + RefreshCw, + Phone, + Briefcase, + Building, + ChevronDown, + ChevronRight, + UserPlus } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; import { toast } from "sonner"; -import { cn } from "@/lib/utils"; +import { cn, formatDate } from "@/lib/utils"; import { Tooltip, TooltipContent, @@ -47,16 +53,54 @@ import { } from "@/components/ui/tooltip"; import { Alert, - AlertDescription, + AlertDescription, AlertTitle } from "@/components/ui/alert"; - +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" // 타입 정의 +interface ContactDetail { + id: number; + name: string; + position?: string | null; + department?: string | null; + email: string; + phone?: string | null; + isPrimary: boolean; +} + +interface CustomEmail { + id: string; + email: string; + name?: string; +} + interface Vendor { vendorId: number; vendorName: string; vendorCode?: string | null; vendorCountry?: string | null; vendorEmail?: string | null; + representativeEmail?: string | null; + contacts?: ContactDetail[]; + contactsByPosition?: Record<string, ContactDetail[]>; + primaryEmail?: string | null; currency?: string | null; } @@ -93,7 +137,9 @@ interface RfqInfo { } interface VendorWithRecipients extends Vendor { - additionalRecipients: string[]; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails: CustomEmail[]; } interface SendRfqDialogProps { @@ -109,6 +155,12 @@ interface SendRfqDialogProps { }) => Promise<void>; } +// 이메일 유효성 검사 함수 +const validateEmail = (email: string): boolean => { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); +}; + // 첨부파일 타입별 아이콘 const getAttachmentIcon = (type: string) => { switch (type.toLowerCase()) { @@ -132,6 +184,40 @@ const formatFileSize = (bytes?: number) => { return `${kb.toFixed(2)} KB`; }; +// 포지션별 아이콘 +const getPositionIcon = (position?: string | null) => { + if (!position) return <User className="h-3 w-3" />; + + const lowerPosition = position.toLowerCase(); + if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { + return <Building2 className="h-3 w-3" />; + } + if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { + return <Briefcase className="h-3 w-3" />; + } + if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { + return <Package className="h-3 w-3" />; + } + return <User className="h-3 w-3" />; +}; + +// 포지션별 색상 +const getPositionColor = (position?: string | null) => { + if (!position) return 'default'; + + const lowerPosition = position.toLowerCase(); + if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { + return 'destructive'; + } + if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { + return 'success'; + } + if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { + return 'secondary'; + } + return 'default'; +}; + export function SendRfqDialog({ open, onOpenChange, @@ -144,6 +230,9 @@ export function SendRfqDialog({ const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]); const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]); const [additionalMessage, setAdditionalMessage] = React.useState(""); + const [expandedVendors, setExpandedVendors] = React.useState<number[]>([]); + const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({}); + const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({}); // 초기화 React.useEffect(() => { @@ -151,48 +240,135 @@ export function SendRfqDialog({ setVendorsWithRecipients( selectedVendors.map(v => ({ ...v, - additionalRecipients: [] + selectedMainEmail: v.primaryEmail || v.vendorEmail || '', + additionalEmails: [], + customEmails: [] })) ); // 모든 첨부파일 선택 setSelectedAttachments(attachments.map(a => a.id)); + // 첫 번째 벤더를 자동으로 확장 + if (selectedVendors.length > 0) { + setExpandedVendors([selectedVendors[0].vendorId]); + } + // 초기화 + setCustomEmailInputs({}); + setShowCustomEmailForm({}); } }, [open, selectedVendors, attachments]); - // 추가 수신처 이메일 추가 - const handleAddRecipient = (vendorId: number, email: string) => { - if (!email) return; - - // 이메일 유효성 검사 - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { + // 커스텀 이메일 추가 + const addCustomEmail = (vendorId: number) => { + const input = customEmailInputs[vendorId]; + if (!input || !input.email) { + toast.error("이메일 주소를 입력해주세요."); + return; + } + + if (!validateEmail(input.email)) { toast.error("올바른 이메일 형식이 아닙니다."); return; } setVendorsWithRecipients(prev => - prev.map(v => - v.vendorId === vendorId - ? { ...v, additionalRecipients: [...v.additionalRecipients, email] } - : v - ) + prev.map(v => { + if (v.vendorId !== vendorId) return v; + + // 중복 체크 + const allEmails = [ + v.vendorEmail, + v.representativeEmail, + ...(v.contacts?.map(c => c.email) || []), + ...v.customEmails.map(c => c.email) + ].filter(Boolean); + + if (allEmails.includes(input.email)) { + toast.error("이미 등록된 이메일 주소입니다."); + return v; + } + + const newCustomEmail: CustomEmail = { + id: `custom-${Date.now()}`, + email: input.email, + name: input.name || input.email.split('@')[0] + }; + + return { + ...v, + customEmails: [...v.customEmails, newCustomEmail] + }; + }) ); + + // 입력 필드 초기화 + setCustomEmailInputs(prev => ({ + ...prev, + [vendorId]: { email: '', name: '' } + })); + setShowCustomEmailForm(prev => ({ + ...prev, + [vendorId]: false + })); + + toast.success("수신자가 추가되었습니다."); }; - // 추가 수신처 이메일 제거 - const handleRemoveRecipient = (vendorId: number, index: number) => { + // 커스텀 이메일 삭제 + const removeCustomEmail = (vendorId: number, emailId: string) => { + setVendorsWithRecipients(prev => + prev.map(v => { + if (v.vendorId !== vendorId) return v; + + const emailToRemove = v.customEmails.find(e => e.id === emailId); + if (!emailToRemove) return v; + + return { + ...v, + customEmails: v.customEmails.filter(e => e.id !== emailId), + // 만약 삭제하는 이메일이 선택된 주 수신자라면 초기화 + selectedMainEmail: v.selectedMainEmail === emailToRemove.email ? '' : v.selectedMainEmail, + // 추가 수신자에서도 제거 + additionalEmails: v.additionalEmails.filter(e => e !== emailToRemove.email) + }; + }) + ); + }; + + // 주 수신자 이메일 변경 + const handleMainEmailChange = (vendorId: number, email: string) => { setVendorsWithRecipients(prev => prev.map(v => v.vendorId === vendorId - ? { - ...v, - additionalRecipients: v.additionalRecipients.filter((_, i) => i !== index) - } + ? { ...v, selectedMainEmail: email } : v ) ); }; + // 추가 수신자 토글 + const toggleAdditionalEmail = (vendorId: number, email: string) => { + setVendorsWithRecipients(prev => + prev.map(v => { + if (v.vendorId !== vendorId) return v; + + const additionalEmails = v.additionalEmails.includes(email) + ? v.additionalEmails.filter(e => e !== email) + : [...v.additionalEmails, email]; + + return { ...v, additionalEmails }; + }) + ); + }; + + // 벤더 확장/축소 토글 + const toggleVendorExpand = (vendorId: number) => { + setExpandedVendors(prev => + prev.includes(vendorId) + ? prev.filter(id => id !== vendorId) + : [...prev, vendorId] + ); + }; + // 첨부파일 선택 토글 const toggleAttachment = (attachmentId: number) => { setSelectedAttachments(prev => @@ -206,15 +382,24 @@ export function SendRfqDialog({ const handleSend = async () => { try { setIsSending(true); - + // 유효성 검사 + const vendorsWithoutEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail); + if (vendorsWithoutEmail.length > 0) { + toast.error(`${vendorsWithoutEmail.map(v => v.vendorName).join(', ')}의 주 수신자를 선택해주세요.`); + return; + } + if (selectedAttachments.length === 0) { toast.warning("최소 하나 이상의 첨부파일을 선택해주세요."); return; } await onSend({ - vendors: vendorsWithRecipients, + vendors: vendorsWithRecipients.map(v => ({ + ...v, + additionalRecipients: v.additionalEmails, + })), attachments: selectedAttachments, message: additionalMessage, }); @@ -231,14 +416,14 @@ export function SendRfqDialog({ // 총 수신자 수 계산 const totalRecipientCount = React.useMemo(() => { - return vendorsWithRecipients.reduce((acc, v) => - acc + 1 + v.additionalRecipients.length, 0 + return vendorsWithRecipients.reduce((acc, v) => + acc + 1 + v.additionalEmails.length, 0 ); }, [vendorsWithRecipients]); return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col"> + <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col"> <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Send className="h-5 w-5" /> @@ -249,7 +434,8 @@ export function SendRfqDialog({ </DialogDescription> </DialogHeader> - <ScrollArea className="flex-1 max-h-[calc(90vh-200px)]"> + {/* ScrollArea 대신 div 사용 */} + <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(90vh - 200px)' }}> <div className="space-y-6 pr-4"> {/* RFQ 정보 섹션 */} <div className="space-y-4"> @@ -257,88 +443,367 @@ export function SendRfqDialog({ <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> + <span className="text-muted-foreground min-w-[80px]">RFQ 코드:</span> + <span className="font-medium">{rfqInfo?.rfqCode}</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 className="text-muted-foreground min-w-[80px]">견적마감일:</span> + <span className="font-medium text-red-600"> + {formatDate(rfqInfo?.dueDate, "KR")} </span> </div> <div className="flex items-start gap-2"> - <span className="text-muted-foreground min-w-[80px]">설계담당:</span> - <span> - {rfqInfo.designPicName || "이*진"} {rfqInfo.designTeam || "전장설계팀 (전장기기시스템)"} + <span className="text-muted-foreground min-w-[80px]">프로젝트:</span> + <span className="font-medium"> + {rfqInfo?.projectCode} ({rfqInfo?.projectName}) </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 className="text-muted-foreground min-w-[80px]"> 자재그룹:</span> + <span className="font-medium"> + {rfqInfo?.packageNo} - {rfqInfo?.packageName} </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 className="text-muted-foreground min-w-[80px]">구매담당자:</span> + <span className="font-medium"> + {rfqInfo?.picName} ({rfqInfo?.picCode}) {rfqInfo?.picTeam} </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 className="text-muted-foreground min-w-[80px]"> 설계담당자:</span> + <span className="font-medium"> + {rfqInfo?.designPicName} </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> + {rfqInfo?.rfqCode.startsWith("F") && + <> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">견적명:</span> + <span className="font-medium"> + {rfqInfo?.rfqTitle} + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]"> 견적종류:</span> + <span className="font-medium"> + {rfqInfo?.rfqType} + </span> + </div> + </> + } </div> + </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> + <Separator /> + + {/* 수신 업체 섹션 - 테이블 버전 with 인라인 추가 폼 */} + <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> - {/* 계약구분 (일반견적일 때만) */} - {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 className="border rounded-lg overflow-hidden"> + <table className="w-full"> + <thead className="bg-muted/50 border-b"> + <tr> + <th className="text-left p-2 text-xs font-medium">No.</th> + <th className="text-left p-2 text-xs font-medium">업체명</th> + <th className="text-left p-2 text-xs font-medium">주 수신자</th> + <th className="text-left p-2 text-xs font-medium">CC</th> + <th className="text-left p-2 text-xs font-medium">작업</th> + </tr> + </thead> + <tbody> + {vendorsWithRecipients.map((vendor, index) => { + const allContacts = vendor.contacts || []; + const allEmails = [ + ...(vendor.representativeEmail ? [{ + value: vendor.representativeEmail, + label: '대표자', + email: vendor.representativeEmail, + type: 'representative' + }] : []), + ...allContacts.map(c => ({ + value: c.email, + label: `${c.name} ${c.position ? `(${c.position})` : ''}`, + email: c.email, + type: 'contact' + })), + ...vendor.customEmails.map(c => ({ + value: c.email, + label: c.name || c.email, + email: c.email, + type: 'custom' + })), + ...(vendor.vendorEmail && vendor.vendorEmail !== vendor.representativeEmail ? [{ + value: vendor.vendorEmail, + label: '업체 기본', + email: vendor.vendorEmail, + type: 'default' + }] : []) + ]; + + const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail); + const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail); + const isFormOpen = showCustomEmailForm[vendor.vendorId]; + + return ( + <React.Fragment key={vendor.vendorId}> + <tr className="border-b hover:bg-muted/20"> + <td className="p-2"> + <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> + {index + 1} + </div> + </td> + <td className="p-2"> + <div className="space-y-1"> + <div className="font-medium text-sm">{vendor.vendorName}</div> + <div className="flex items-center gap-1"> + <Badge variant="outline" className="text-xs"> + {vendor.vendorCountry} + </Badge> + <span className="text-xs text-muted-foreground"> + {vendor.vendorCode} + </span> + </div> + </div> + </td> + <td className="p-2"> + <Select + value={vendor.selectedMainEmail} + onValueChange={(value) => handleMainEmailChange(vendor.vendorId, value)} + > + <SelectTrigger className="h-7 text-xs w-[200px]"> + <SelectValue placeholder="선택하세요"> + {selectedMainEmailInfo && ( + <div className="flex items-center gap-1"> + {selectedMainEmailInfo.type === 'representative' && <Building2 className="h-3 w-3" />} + {selectedMainEmailInfo.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />} + <span className="truncate">{selectedMainEmailInfo.label}</span> + </div> + )} + </SelectValue> + </SelectTrigger> + <SelectContent> + {allEmails.map((email) => ( + <SelectItem key={email.value} value={email.value} className="text-xs"> + <div className="flex items-center gap-1"> + {email.type === 'representative' && <Building2 className="h-3 w-3" />} + {email.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />} + <span>{email.label}</span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + {!vendor.selectedMainEmail && ( + <span className="text-xs text-red-500">필수</span> + )} + </td> + <td className="p-2"> + <Popover> + <PopoverTrigger asChild> + <Button variant="outline" className="h-7 text-xs"> + {vendor.additionalEmails.length > 0 + ? `${vendor.additionalEmails.length}명` + : "선택" + } + <ChevronDown className="ml-1 h-3 w-3" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-48 p-2"> + <div className="max-h-48 overflow-y-auto space-y-1"> + {ccEmails.map((email) => ( + <div key={email.value} className="flex items-center space-x-1 p-1"> + <Checkbox + checked={vendor.additionalEmails.includes(email.value)} + onCheckedChange={() => toggleAdditionalEmail(vendor.vendorId, email.value)} + className="h-3 w-3" + /> + <label className="text-xs cursor-pointer flex-1 truncate"> + {email.label} + </label> + </div> + ))} + </div> + </PopoverContent> + </Popover> + </td> + <td className="p-2"> + <div className="flex items-center gap-1"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant={isFormOpen ? "default" : "ghost"} + size="sm" + className="h-6 w-6 p-0" + onClick={() => { + setShowCustomEmailForm(prev => ({ + ...prev, + [vendor.vendorId]: !prev[vendor.vendorId] + })); + }} + > + {isFormOpen ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />} + </Button> + </TooltipTrigger> + <TooltipContent>{isFormOpen ? "닫기" : "수신자 추가"}</TooltipContent> + </Tooltip> + </TooltipProvider> + {vendor.customEmails.length > 0 && ( + <Badge variant="success" className="text-xs"> + +{vendor.customEmails.length} + </Badge> + )} + </div> + </td> + </tr> + + {/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */} + {isFormOpen && ( + <tr className="bg-muted/10 border-b"> + <td colSpan={5} className="p-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2 text-sm font-medium"> + <UserPlus className="h-4 w-4" /> + 수신자 추가 - {vendor.vendorName} + </div> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0" + onClick={() => setShowCustomEmailForm(prev => ({ + ...prev, + [vendor.vendorId]: false + }))} + > + <X className="h-3 w-3" /> + </Button> + </div> + + {/* 한 줄에 모든 요소 배치 - 명확한 너비 지정 */} + <div className="flex gap-2 items-end"> + <div className="w-[150px]"> + <Label className="text-xs mb-1 block">이름 (선택)</Label> + <Input + placeholder="홍길동" + className="h-8 text-sm" + value={customEmailInputs[vendor.vendorId]?.name || ''} + onChange={(e) => setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + name: e.target.value + } + }))} + /> + </div> + <div className="flex-1"> + <Label className="text-xs mb-1 block">이메일 <span className="text-red-500">*</span></Label> + <Input + type="email" + placeholder="example@company.com" + className="h-8 text-sm" + value={customEmailInputs[vendor.vendorId]?.email || ''} + onChange={(e) => setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + email: e.target.value + } + }))} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addCustomEmail(vendor.vendorId); + } + }} + /> + </div> + <Button + size="sm" + className="h-8 px-4" + onClick={() => addCustomEmail(vendor.vendorId)} + disabled={!customEmailInputs[vendor.vendorId]?.email} + > + <Plus className="h-3 w-3 mr-1" /> + 추가 + </Button> + <Button + variant="outline" + size="sm" + className="h-8 px-4" + onClick={() => { + setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { email: '', name: '' } + })); + setShowCustomEmailForm(prev => ({ + ...prev, + [vendor.vendorId]: false + })); + }} + > + 취소 + </Button> + </div> + + {/* 추가된 커스텀 이메일 목록 */} + {vendor.customEmails.length > 0 && ( + <div className="mt-3 pt-3 border-t"> + <div className="text-xs text-muted-foreground mb-2">추가된 수신자 목록</div> + <div className="grid grid-cols-2 xl:grid-cols-3 gap-2"> + {vendor.customEmails.map((custom) => ( + <div key={custom.id} className="flex items-center justify-between bg-background rounded-md p-2"> + <div className="flex items-center gap-2 min-w-0"> + <UserPlus className="h-3 w-3 text-green-500 flex-shrink-0" /> + <div className="min-w-0"> + <div className="text-sm font-medium truncate">{custom.name}</div> + <div className="text-xs text-muted-foreground truncate">{custom.email}</div> + </div> + </div> + <Button + variant="ghost" + size="sm" + className="h-6 w-6 p-0 flex-shrink-0" + onClick={() => removeCustomEmail(vendor.vendorId, custom.id)} + > + <X className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + </div> + )} + </div> + </td> + </tr> + )} + </React.Fragment> + ); + })} + </tbody> + </table> </div> </div> - <Separator /> + <Separator /> {/* 첨부파일 섹션 */} <div className="space-y-4"> <div className="flex items-center justify-between"> @@ -361,45 +826,30 @@ export function SendRfqDialog({ </Button> </div> - <div className="border rounded-lg divide-y"> + <div className="border rounded-lg divide-y max-h-40 overflow-y-auto"> {attachments.length > 0 ? ( attachments.map((attachment) => ( <div key={attachment.id} - className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors" + className="flex items-center justify-between p-2 hover:bg-muted/50 transition-colors" > - <div className="flex items-center gap-3"> + <div className="flex items-center gap-2"> <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 className="text-sm"> + {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} </span> + <Badge variant="outline" className="text-xs"> + {attachment.currentRevision} + </Badge> </div> </div> )) ) : ( - <div className="p-8 text-center text-muted-foreground"> - <Paperclip className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <div className="p-4 text-center text-muted-foreground"> <p className="text-sm">첨부파일이 없습니다.</p> </div> )} @@ -408,124 +858,7 @@ export function SendRfqDialog({ <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"> 추가 메시지 (선택사항) @@ -539,14 +872,16 @@ export function SendRfqDialog({ /> </div> </div> - </ScrollArea> + </div> <DialogFooter className="flex-shrink-0"> - <Alert className="mr-auto max-w-md"> - <AlertCircle className="h-4 w-4" /> - <AlertDescription className="text-xs"> - 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. - </AlertDescription> + <Alert className="max-w-md"> + <div className="flex items-center gap-2"> + <AlertCircle className="h-4 w-4 flex-shrink-0" /> + <AlertDescription className="text-xs"> + 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. + </AlertDescription> + </div> </Alert> <Button variant="outline" |
