From c62ec046327fd388ebce04571b55910747e69a3b Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 9 Sep 2025 10:32:34 +0000 Subject: (정희성, 최겸, 대표님) formatDate 변경 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rfq-last/service.ts | 310 +++++++++++- lib/rfq-last/vendor/rfq-vendor-table.tsx | 205 +++++--- lib/rfq-last/vendor/send-rfq-dialog.tsx | 803 ++++++++++++++++++++++--------- 3 files changed, 1022 insertions(+), 296 deletions(-) (limited to 'lib/rfq-last') diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 0c75e72f..ac7104df 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3,7 +3,7 @@ import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView} from "@/db/schema"; +import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts,projects} from "@/db/schema"; import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; @@ -1570,18 +1570,27 @@ export async function getRfqVendorResponses(rfqId: number) { ) .orderBy(desc(rfqLastVendorResponses.createdAt)); + if (!vendorResponsesData || vendorResponsesData.length === 0) { + return { + success: true, + data: [], + rfq: rfqData[0], + details: details, + }; + } + // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산 const vendorResponsesWithCounts = await Promise.all( vendorResponsesData.map(async (response) => { // 견적 아이템 수 조회 const itemCount = await db - .select({ count: sql`COUNT(*)::int` }) + .select({ count: count()}) .from(rfqLastVendorQuotationItems) .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)); // 첨부파일 수 조회 const attachmentCount = await db - .select({ count: sql`COUNT(*)::int` }) + .select({ count: count()}) .from(rfqLastVendorAttachments) .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)); @@ -1594,7 +1603,8 @@ export async function getRfqVendorResponses(rfqId: number) { ); // 5. 응답 데이터 정리 - const formattedResponses = vendorResponsesWithCounts.map(response => ({ + const formattedResponses = vendorResponsesWithCounts + .filter(response => response && response.id).map(response => ({ id: response.id, rfqsLastId: response.rfqsLastId, rfqLastDetailsId: response.rfqLastDetailsId, @@ -2311,4 +2321,296 @@ export async function getRfqVendors(rfqId: number) { export async function getRfqAttachments(rfqId: number) { const fullInfo = await getRfqFullInfo(rfqId); return fullInfo.attachments; +} + + +// RFQ 발송용 데이터 타입 +export interface RfqSendData { + 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; + }; + attachments: Array<{ + id: number; + attachmentType: string; + serialNo: string; + currentRevision: string; + description?: string | null; + fileName?: string | null; + fileSize?: number | null; + uploadedAt?: Date; + }>; +} + +// 선택된 벤더의 이메일 정보 조회 +export interface VendorEmailInfo { + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + vendorEmail?: string | null; // vendors 테이블의 기본 이메일 + representativeEmail?: string | null; // 대표자 이메일 + contactEmails: string[]; // 영업/대표 담당자 이메일들 + primaryEmail?: string | null; // 최종 선택된 주 이메일 + currency?: string | null; +} + +/** + * RFQ 발송 다이얼로그용 데이터 조회 + */ +export async function getRfqSendData(rfqId: number): Promise { + try { + // 1. RFQ 기본 정보 조회 + const [rfqData] = await db + .select({ + rfq: rfqsLast, + project: projects, + picUser: users, + }) + .from(rfqsLast) + .leftJoin(projects, eq(rfqsLast.projectId, projects.id)) + .leftJoin(users, eq(rfqsLast.pic, users.id)) + .where(eq(rfqsLast.id, rfqId)) + .limit(1); + + if (!rfqData) { + throw new Error(`RFQ ID ${rfqId}를 찾을 수 없습니다.`); + } + + const { rfq, project, picUser } = rfqData; + + // 2. PR Items에서 자재그룹 정보 조회 (Major Item) + const [majorItem] = await db + .select({ + materialCategory: rfqPrItems.materialCategory, + materialDescription: rfqPrItems.materialDescription, + }) + .from(rfqPrItems) + .where(and( + eq(rfqPrItems.rfqsLastId, rfqId), + eq(rfqPrItems.majorYn, true) + )) + .limit(1); + + // 3. 첨부파일 정보 조회 + const attachmentsData = await db + .select({ + attachment: rfqLastAttachments, + revision: rfqLastAttachmentRevisions, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .where(eq(rfqLastAttachments.rfqId, rfqId)); + + const attachments = attachmentsData.map(a => ({ + id: a.attachment.id, + attachmentType: a.attachment.attachmentType, + serialNo: a.attachment.serialNo, + currentRevision: a.attachment.currentRevision, + description: a.attachment.description, + fileName: a.revision?.originalFileName ?? null, + fileSize: a.revision?.fileSize ?? null, + uploadedAt: a.attachment.createdAt, + })); + + // 4. RFQ 정보 조합 + const rfqInfo = { + rfqCode: rfq.rfqCode || '', + rfqTitle: rfq.rfqTitle || '', + rfqType: rfq.rfqType || '', + projectCode: project?.code || undefined, + projectName: project?.name || undefined, + picName: rfq.picName || undefined, + picCode: rfq.picCode || undefined, + picTeam: picUser?.deptName || undefined, + packageNo: rfq.packageNo || undefined, + packageName: rfq.packageName || undefined, + designPicName: rfq.EngPicName || undefined, + rfqTitle: rfq.rfqTitle || undefined, + rfqType: rfq.rfqType || undefined, + designTeam: undefined, // 필요시 추가 조회 + materialGroup: majorItem?.materialCategory || undefined, + materialGroupDesc: majorItem?.materialDescription || undefined, + dueDate: rfq.dueDate || new Date(), + quotationType: rfq.rfqType || undefined, + evaluationApply: true, // 기본값 또는 별도 필드 + contractType: undefined, // 필요시 추가 + }; + + return { + rfqInfo, + attachments, + }; + } catch (error) { + console.error("RFQ 발송 데이터 조회 실패:", error); + throw error; + } +} + +interface ContactDetail { + id: number; + name: string; + position?: string | null; + department?: string | null; + email: string; + phone?: string | null; + isPrimary: boolean; +} + +/** + * 벤더 이메일 정보 조회 + */ +export async function getVendorEmailInfo(vendorIds: number[]): Promise { + try { + // 1. 벤더 기본 정보 조회 + const vendorsData = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + country: vendors.country, + email: vendors.email, + representativeEmail: vendors.representativeEmail, + }) + .from(vendors) + .where(sql`${vendors.id} IN ${vendorIds}`); + + // 2. 각 벤더의 모든 담당자 정보 조회 + const contactsData = await db + .select({ + id: vendorContacts.id, + vendorId: vendorContacts.vendorId, + contactName: vendorContacts.contactName, + contactPosition: vendorContacts.contactPosition, + contactDepartment: vendorContacts.contactDepartment, + contactEmail: vendorContacts.contactEmail, + contactPhone: vendorContacts.contactPhone, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(sql`${vendorContacts.vendorId} IN ${vendorIds}`); + + // 3. 데이터 조합 + const vendorEmailInfos: VendorEmailInfo[] = vendorsData.map(vendor => { + const vendorContacts = contactsData.filter(c => c.vendorId === vendor.id); + + // ContactDetail 형식으로 변환 + const contacts: ContactDetail[] = vendorContacts.map(c => ({ + id: c.id, + name: c.contactName, + position: c.contactPosition, + department: c.contactDepartment, + email: c.contactEmail, + phone: c.contactPhone, + isPrimary: c.isPrimary, + })); + + // 포지션별로 그룹화 + const contactsByPosition: Record = {}; + contacts.forEach(contact => { + const position = contact.position || '기타'; + if (!contactsByPosition[position]) { + contactsByPosition[position] = []; + } + contactsByPosition[position].push(contact); + }); + + // 주 이메일 선택 우선순위: + // 1. isPrimary가 true인 담당자 이메일 + // 2. 대표자 이메일 + // 3. vendors 테이블의 기본 이메일 + // 4. 영업 담당자 이메일 + // 5. 첫번째 담당자 이메일 + const primaryContact = contacts.find(c => c.isPrimary); + const salesContact = contacts.find(c => c.position === '영업'); + const primaryEmail = + primaryContact?.email || + vendor.representativeEmail || + vendor.email || + salesContact?.email || + contacts[0]?.email || + null; + + return { + vendorId: vendor.id, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + vendorCountry: vendor.country, + vendorEmail: vendor.email, + representativeEmail: vendor.representativeEmail, + contacts, + contactsByPosition, + primaryEmail, + currency: 'KRW', // 기본값, 필요시 별도 조회 + }; + }); + + return vendorEmailInfos; + } catch (error) { + console.error("벤더 이메일 정보 조회 실패:", error); + throw error; + } +} + +/** + * 선택된 벤더들의 상세 정보 조회 (RFQ Detail 포함) + */ +export async function getSelectedVendorsWithEmails( + rfqId: number, + vendorIds: number[] +): Promise> { + try { + // 1. 벤더 이메일 정보 조회 + const vendorEmailInfos = await getVendorEmailInfo(vendorIds); + + // 2. RFQ Detail에서 통화 정보 조회 (옵션) + const rfqDetailsData = await db + .select({ + vendorId: rfqLastDetails.vendorsId, + currency: rfqLastDetails.currency, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + sql`${rfqLastDetails.vendorsId} IN ${vendorIds}` + ) + ); + + // 3. 통화 정보 병합 + const result = vendorEmailInfos.map(vendor => { + const detail = rfqDetailsData.find(d => d.vendorId === vendor.vendorId); + return { + ...vendor, + currency: detail?.currency || vendor.currency || 'KRW', + }; + }); + + return result; + } catch (error) { + console.error("선택된 벤더 정보 조회 실패:", error); + throw error; + } } \ No newline at end of file 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(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[] = React.useMemo(() => [ { id: "select", @@ -535,7 +645,6 @@ export function RfqVendorTable({ header: ({ column }) => , cell: ({ row }) => { const submittedAt = row.original.response?.submittedAt; - const status = row.original.response?.status; if (!submittedAt) { return 미참여; @@ -639,7 +748,10 @@ export function RfqVendorTable({ 상세보기 {!hasResponse && ( - handleAction("send", vendor)}> + handleAction("send", vendor)} + disabled={isLoadingSendData} + > RFQ 발송 @@ -662,7 +774,7 @@ export function RfqVendorTable({ }, size: 60, }, - ], [handleAction, rfqCode]); + ], [handleAction, rfqCode, isLoadingSendData]); const advancedFilterFields: DataTableAdvancedFilterField[] = [ { 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(() => (
@@ -743,6 +820,7 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={() => setIsAddDialogOpen(true)} + disabled={isLoadingSendData} > 벤더 추가 @@ -753,6 +831,7 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={() => setIsBatchUpdateOpen(true)} + disabled={isLoadingSendData} > 정보 일괄 입력 ({selectedRows.length}) @@ -761,9 +840,19 @@ export function RfqVendorTable({ variant="outline" size="sm" onClick={handleBulkSend} + disabled={isLoadingSendData || selectedRows.length === 0} > - - 선택 발송 ({selectedRows.length}) + {isLoadingSendData ? ( + <> + + 데이터 준비중... + + ) : ( + <> + + 선택 발송 ({selectedRows.length}) + + )} )} @@ -777,13 +866,13 @@ export function RfqVendorTable({ toast.success("데이터를 새로고침했습니다."); }, 1000); }} - disabled={isRefreshing} + disabled={isRefreshing || isLoadingSendData} > 새로고침
- ), [selectedRows, isRefreshing, handleBulkSend]); + ), [selectedRows, isRefreshing, isLoadingSendData, handleBulkSend]); return ( <> @@ -828,9 +917,9 @@ export function RfqVendorTable({ 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; + 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; } +// 이메일 유효성 검사 함수 +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 ; + + const lowerPosition = position.toLowerCase(); + if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { + return ; + } + if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { + return ; + } + if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { + return ; + } + return ; +}; + +// 포지션별 색상 +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([]); const [selectedAttachments, setSelectedAttachments] = React.useState([]); const [additionalMessage, setAdditionalMessage] = React.useState(""); + const [expandedVendors, setExpandedVendors] = React.useState([]); + const [customEmailInputs, setCustomEmailInputs] = React.useState>({}); + const [showCustomEmailForm, setShowCustomEmailForm] = React.useState>({}); // 초기화 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 ( - + @@ -249,7 +434,8 @@ export function SendRfqDialog({ - + {/* ScrollArea 대신 div 사용 */} +
{/* RFQ 정보 섹션 */}
@@ -257,88 +443,367 @@ export function SendRfqDialog({ RFQ 정보
- +
- {/* 프로젝트 정보 */}
- 프로젝트: - - {rfqInfo.projectCode || "PN003"} ({rfqInfo.projectName || "PETRONAS ZLNG nearshore project"}) - + RFQ 코드: + {rfqInfo?.rfqCode}
- 견적번호: - {rfqInfo.rfqCode} -
-
- - {/* 담당자 정보 */} -
-
- 구매담당: - - {rfqInfo.picName || "김*종"} ({rfqInfo.picCode || "86D"}) {rfqInfo.picTeam || "해양구매팀(해양구매1)"} + 견적마감일: + + {formatDate(rfqInfo?.dueDate, "KR")}
- 설계담당: - - {rfqInfo.designPicName || "이*진"} {rfqInfo.designTeam || "전장설계팀 (전장기기시스템)"} + 프로젝트: + + {rfqInfo?.projectCode} ({rfqInfo?.projectName})
-
- - {/* PKG 및 자재 정보 */} -
- PKG 정보: - - {rfqInfo.packageNo || "MM03"} ({rfqInfo.packageName || "Deck Machinery"}) + 자재그룹: + + {rfqInfo?.packageNo} - {rfqInfo?.packageName}
- 자재그룹: - - {rfqInfo.materialGroup || "BE2101"} ({rfqInfo.materialGroupDesc || "Combined Windlass & Mooring Wi"}) + 구매담당자: + + {rfqInfo?.picName} ({rfqInfo?.picCode}) {rfqInfo?.picTeam}
-
- - {/* 견적 정보 */} -
- 견적마감일: - - {format(rfqInfo.dueDate, "yyyy.MM.dd", { locale: ko })} + 설계담당자: + + {rfqInfo?.designPicName}
-
- 평가적용: - - {rfqInfo.evaluationApply ? "Y" : "N"} - -
+ {rfqInfo?.rfqCode.startsWith("F") && + <> +
+ 견적명: + + {rfqInfo?.rfqTitle} + +
+
+ 견적종류: + + {rfqInfo?.rfqType} + +
+ + }
+
+
- {/* 견적명 */} -
- 견적명: - {rfqInfo.rfqTitle} + + + {/* 수신 업체 섹션 - 테이블 버전 with 인라인 추가 폼 */} +
+
+
+ + 수신 업체 ({selectedVendors.length})
+ + + 총 {totalRecipientCount}명 + +
- {/* 계약구분 (일반견적일 때만) */} - {rfqInfo.rfqType === "일반견적" && ( -
- 계약구분: - {rfqInfo.contractType || "-"} -
- )} +
+ + + + + + + + + + + + {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 ( + + + + + + + + + + {/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */} + {isFormOpen && ( + + + + )} + + ); + })} + +
No.업체명주 수신자CC작업
+
+ {index + 1} +
+
+
+
{vendor.vendorName}
+
+ + {vendor.vendorCountry} + + + {vendor.vendorCode} + +
+
+
+ + {!vendor.selectedMainEmail && ( + 필수 + )} + + + + + + +
+ {ccEmails.map((email) => ( +
+ toggleAdditionalEmail(vendor.vendorId, email.value)} + className="h-3 w-3" + /> + +
+ ))} +
+
+
+
+
+ + + + + + {isFormOpen ? "닫기" : "수신자 추가"} + + + {vendor.customEmails.length > 0 && ( + + +{vendor.customEmails.length} + + )} +
+
+
+
+
+ + 수신자 추가 - {vendor.vendorName} +
+ +
+ + {/* 한 줄에 모든 요소 배치 - 명확한 너비 지정 */} +
+
+ + setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + name: e.target.value + } + }))} + /> +
+
+ + setCustomEmailInputs(prev => ({ + ...prev, + [vendor.vendorId]: { + ...prev[vendor.vendorId], + email: e.target.value + } + }))} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addCustomEmail(vendor.vendorId); + } + }} + /> +
+ + +
+ + {/* 추가된 커스텀 이메일 목록 */} + {vendor.customEmails.length > 0 && ( +
+
추가된 수신자 목록
+
+ {vendor.customEmails.map((custom) => ( +
+
+ +
+
{custom.name}
+
{custom.email}
+
+
+ +
+ ))} +
+
+ )} +
+
- + {/* 첨부파일 섹션 */}
@@ -361,45 +826,30 @@ export function SendRfqDialog({
-
+
{attachments.length > 0 ? ( attachments.map((attachment) => (
-
+
toggleAttachment(attachment.id)} /> {getAttachmentIcon(attachment.attachmentType)} -
-
- - {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} - - - {attachment.currentRevision} - -
- {attachment.description && ( -

- {attachment.description} -

- )} -
-
-
- - {formatFileSize(attachment.fileSize)} + + {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} + + {attachment.currentRevision} +
)) ) : ( -
- +

첨부파일이 없습니다.

)} @@ -408,124 +858,7 @@ export function SendRfqDialog({ - {/* 수신 업체 섹션 */} -
-
-
- - 수신 업체 ({selectedVendors.length}) -
- - - 총 {totalRecipientCount}명 - -
- -
- {vendorsWithRecipients.map((vendor, index) => ( -
- {/* 업체 정보 */} -
-
-
- {index + 1} -
-
-
- {vendor.vendorName} - - {vendor.vendorCountry} - -
- {vendor.vendorCode && ( - - {vendor.vendorCode} - - )} -
-
- - 주 수신: {vendor.vendorEmail || "vendor@example.com"} - -
- - {/* 추가 수신처 */} -
-
- - - - - - - -

참조로 RFQ를 받을 추가 이메일 주소를 입력하세요.

-
-
-
-
- - {/* 추가된 이메일 목록 */} -
- {vendor.additionalRecipients.map((email, idx) => ( - - - {email} - - - ))} -
- - {/* 이메일 입력 필드 */} -
- { - if (e.key === "Enter") { - e.preventDefault(); - const input = e.target as HTMLInputElement; - handleAddRecipient(vendor.vendorId, input.value); - input.value = ""; - } - }} - /> - -
-
-
- ))} -
-
- - - - {/* 추가 메시지 (선택사항) */} + {/* 추가 메시지 */}
- +
- - - - 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. - + +
+ + + 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. + +