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 | |
| parent | ebcec3f296d1d27943caf8a3aed26efef117cdc5 (diff) | |
(정희성, 최겸, 대표님) formatDate 변경 등
Diffstat (limited to 'lib/rfq-last')
| -rw-r--r-- | lib/rfq-last/service.ts | 310 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 205 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 803 |
3 files changed, 1022 insertions, 296 deletions
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<RfqSendData> { + 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<VendorEmailInfo[]> { + 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<string, ContactDetail[]> = {}; + 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<Array<VendorEmailInfo & { currency?: string | null }>> { + 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<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" |
