diff options
Diffstat (limited to 'lib/rfq-last')
| -rw-r--r-- | lib/rfq-last/service.ts | 480 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/batch-update-conditions-dialog.tsx | 81 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 208 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 578 |
4 files changed, 1266 insertions, 81 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 67cb901f..0c75e72f 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -571,9 +571,9 @@ export async function getRfqItemsAction(rfqId: number) { materialDescription: item.materialDescription, size: item.size, deliveryDate: item.deliveryDate, - quantity: item.quantity, + quantity: Number(item.quantity) || 0, // 여기서 숫자로 변환 uom: item.uom, - grossWeight: item.grossWeight, + grossWeight: Number(item.grossWeight) || 0, // 여기서 숫자로 변환 gwUom: item.gwUom, specNo: item.specNo, specUrl: item.specUrl, @@ -1835,4 +1835,480 @@ export async function getRfqWithDetails(rfqId: number) { console.error("Get RFQ with details error:", error); return { success: false, error: "데이터 조회 중 오류가 발생했습니다." }; } +} + + +// RFQ 정보 타입 +export interface RfqFullInfo { + // 기본 RFQ 정보 + id: number; + rfqCode: string; + rfqType: string | null; + rfqTitle: string | null; + series: string | null; + rfqSealedYn: boolean | null; + + // ITB 관련 + projectCompany: string | null; + projectFlag: string | null; + projectSite: string | null; + smCode: string | null; + + // RFQ 추가 필드 + prNumber: string | null; + prIssueDate: Date | null; + + // 프로젝트 정보 + projectId: number | null; + projectCode: string | null; + projectName: string | null; + + // 아이템 정보 + itemCode: string | null; + itemName: string | null; + + // 패키지 정보 + packageNo: string | null; + packageName: string | null; + + // 날짜 정보 + dueDate: Date | null; + rfqSendDate: Date | null; + + // 상태 + status: string; + + // 담당자 정보 + picId: number | null; + picCode: string | null; + picName: string | null; + picUserName: string | null; + picTeam: string | null; + + // 설계담당자 + engPicName: string | null; + designTeam: string | null; + + // 자재그룹 정보 (PR Items에서) + materialGroup: string | null; + materialGroupDesc: string | null; + + // 카운트 정보 + vendorCount: number; + shortListedVendorCount: number; + quotationReceivedCount: number; + prItemsCount: number; + majorItemsCount: number; + + // 감사 정보 + createdBy: number; + createdByUserName: string | null; + createdAt: Date; + updatedBy: number; + updatedByUserName: string | null; + updatedAt: Date; + + sentBy: number | null; + sentByUserName: string | null; + + remark: string | null; + + // 평가 적용 여부 (추가 필드) + evaluationApply?: boolean; + quotationType?: string; + contractType?: string; + + // 연관 데이터 + vendors: VendorDetail[]; + attachments: AttachmentInfo[]; +} + +// 벤더 상세 정보 +export interface VendorDetail { + detailId: number; + vendorId: number | null; + vendorName: string | null; + vendorCode: string | null; + vendorCountry: string | null; + vendorEmail?: string | null; + vendorCategory?: string | null; + vendorGrade?: string | null; + basicContract?: string | null; + + // RFQ 조건 + currency: string | null; + paymentTermsCode: string | null; + paymentTermsDescription: string | null; + incotermsCode: string | null; + incotermsDescription: string | null; + incotermsDetail: string | null; + deliveryDate: Date | null; + contractDuration: string | null; + taxCode: string | null; + placeOfShipping: string | null; + placeOfDestination: string | null; + + // 상태 + shortList: boolean; + returnYn: boolean; + returnedAt: Date | null; + + // GTC/NDA + prjectGtcYn: boolean; + generalGtcYn: boolean; + ndaYn: boolean; + agreementYn: boolean; + + // 추가 조건 + materialPriceRelatedYn: boolean | null; + sparepartYn: boolean | null; + firstYn: boolean | null; + firstDescription: string | null; + sparepartDescription: string | null; + + remark: string | null; + cancelReason: string | null; + + // 회신 상태 + quotationStatus?: string | null; + quotationSubmittedAt?: Date | null; + + // 업데이트 정보 + updatedBy: number; + updatedByUserName: string | null; + updatedAt: Date | null; +} + +// 첨부파일 정보 +export interface AttachmentInfo { + id: number; + attachmentType: string; + serialNo: string; + currentRevision: string; + description: string | null; + + // 최신 리비전 정보 + fileName: string | null; + originalFileName: string | null; + filePath: string | null; + fileSize: number | null; + fileType: string | null; + + createdBy: number; + createdByUserName: string | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * RFQ 전체 정보 조회 + */ +export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { + try { + // 1. RFQ 기본 정보 조회 + const rfqData = await db + .select({ + rfq: rfqsLast, + picUser: users, + }) + .from(rfqsLast) + .leftJoin(users, eq(rfqsLast.pic, users.id)) + .where(eq(rfqsLast.id, rfqId)) + .limit(1); + + if (!rfqData.length) { + throw new Error(`RFQ ID ${rfqId}를 찾을 수 없습니다.`); + } + + const rfq = rfqData[0].rfq; + const picUser = rfqData[0].picUser; + + // 2. PR Items에서 자재그룹 정보 조회 (Major Item) + const prItemsData = await db + .select({ + materialCategory: rfqPrItems.materialCategory, + materialDescription: rfqPrItems.materialDescription, + prItemsCount: eq(rfqPrItems.majorYn, true), + }) + .from(rfqPrItems) + .where(and( + eq(rfqPrItems.rfqsLastId, rfqId), + eq(rfqPrItems.majorYn, true) + )) + .limit(1); + + const majorItem = prItemsData[0]; + + // 3. 벤더 정보 조회 + const vendorsData = await db + .select({ + detail: rfqLastDetails, + vendor: vendors, + paymentTerms: paymentTerms, + incoterms: incoterms, + updatedByUser: users, + }) + .from(rfqLastDetails) + .leftJoin(vendors, eq(rfqLastDetails.vendorsId, vendors.id)) + .leftJoin(paymentTerms, eq(rfqLastDetails.paymentTermsCode, paymentTerms.code)) + .leftJoin(incoterms, eq(rfqLastDetails.incotermsCode, incoterms.code)) + .leftJoin(users, eq(rfqLastDetails.updatedBy, users.id)) + .where(eq(rfqLastDetails.rfqsLastId, rfqId)); + + const vendorDetails: VendorDetail[] = vendorsData.map(v => ({ + detailId: v.detail.id, + vendorId: v.vendor?.id ?? null, + vendorName: v.vendor?.vendorName ?? null, + vendorCode: v.vendor?.vendorCode ?? null, + vendorCountry: v.vendor?.country ?? null, + vendorEmail: v.vendor?.email ?? null, + vendorCategory: v.vendor?.vendorCategory ?? null, + vendorGrade: v.vendor?.vendorGrade ?? null, + basicContract: v.vendor?.basicContract ?? null, + + currency: v.detail.currency, + paymentTermsCode: v.detail.paymentTermsCode, + paymentTermsDescription: v.paymentTerms?.description ?? null, + incotermsCode: v.detail.incotermsCode, + incotermsDescription: v.incoterms?.description ?? null, + incotermsDetail: v.detail.incotermsDetail, + deliveryDate: v.detail.deliveryDate, + contractDuration: v.detail.contractDuration, + taxCode: v.detail.taxCode, + placeOfShipping: v.detail.placeOfShipping, + placeOfDestination: v.detail.placeOfDestination, + + shortList: v.detail.shortList, + returnYn: v.detail.returnYn, + returnedAt: v.detail.returnedAt, + + prjectGtcYn: v.detail.prjectGtcYn, + generalGtcYn: v.detail.generalGtcYn, + ndaYn: v.detail.ndaYn, + agreementYn: v.detail.agreementYn, + + materialPriceRelatedYn: v.detail.materialPriceRelatedYn, + sparepartYn: v.detail.sparepartYn, + firstYn: v.detail.firstYn, + firstDescription: v.detail.firstDescription, + sparepartDescription: v.detail.sparepartDescription, + + remark: v.detail.remark, + cancelReason: v.detail.cancelReason, + + updatedBy: v.detail.updatedBy, + updatedByUserName: v.updatedByUser?.name ?? null, + updatedAt: v.detail.updatedAt, + })); + + // 4. 첨부파일 정보 조회 + const attachmentsData = await db + .select({ + attachment: rfqLastAttachments, + revision: rfqLastAttachmentRevisions, + createdByUser: users, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id)) + .where(eq(rfqLastAttachments.rfqId, rfqId)); + + const attachments: AttachmentInfo[] = 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?.fileName ?? null, + originalFileName: a.revision?.originalFileName ?? null, + filePath: a.revision?.filePath ?? null, + fileSize: a.revision?.fileSize ?? null, + fileType: a.revision?.fileType ?? null, + + createdBy: a.attachment.createdBy, + createdByUserName: a.createdByUser?.name ?? null, + createdAt: a.attachment.createdAt, + updatedAt: a.attachment.updatedAt, + })); + + // 5. 카운트 정보 계산 + const vendorCount = vendorDetails.length; + const shortListedVendorCount = vendorDetails.filter(v => v.shortList).length; + const quotationReceivedCount = vendorDetails.filter(v => v.quotationSubmittedAt).length; + + // PR Items 카운트 (별도 쿼리 필요) + const prItemsCount = await db + .select({ count: sql<number>`COUNT(*)` }) + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, rfqId)); + + const majorItemsCount = await db + .select({ count: sql<number>`COUNT(*)` }) + .from(rfqPrItems) + .where(and( + eq(rfqPrItems.rfqsLastId, rfqId), + eq(rfqPrItems.majorYn, true) + )); + + // 6. 사용자 정보 조회 (createdBy, updatedBy, sentBy) + const [createdByUser] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, rfq.createdBy)) + .limit(1); + + const [updatedByUser] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, rfq.updatedBy)) + .limit(1); + + const [sentByUser] = rfq.sentBy + ? await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, rfq.sentBy)) + .limit(1) + : [null]; + + // 7. 전체 정보 조합 + const rfqFullInfo: RfqFullInfo = { + // 기본 정보 + id: rfq.id, + rfqCode: rfq.rfqCode ?? '', + rfqType: rfq.rfqType, + rfqTitle: rfq.rfqTitle, + series: rfq.series, + rfqSealedYn: rfq.rfqSealedYn, + + // ITB 관련 + projectCompany: rfq.projectCompany, + projectFlag: rfq.projectFlag, + projectSite: rfq.projectSite, + smCode: rfq.smCode, + + // RFQ 추가 필드 + prNumber: rfq.prNumber, + prIssueDate: rfq.prIssueDate, + + // 프로젝트 + projectId: rfq.projectId, + projectCode: null, // 프로젝트 조인 필요시 추가 + projectName: null, // 프로젝트 조인 필요시 추가 + + // 아이템 + itemCode: rfq.itemCode, + itemName: rfq.itemName, + + // 패키지 + packageNo: rfq.packageNo, + packageName: rfq.packageName, + + // 날짜 + dueDate: rfq.dueDate, + rfqSendDate: rfq.rfqSendDate, + + // 상태 + status: rfq.status, + + // 구매 담당자 + picId: rfq.pic, + picCode: rfq.picCode, + picName: rfq.picName, + picUserName: picUser?.name ?? null, + picTeam: picUser?.department ?? null, // users 테이블에 department 필드가 있다고 가정 + + // 설계 담당자 + engPicName: rfq.EngPicName, + designTeam: null, // 추가 정보 필요시 입력 + + // 자재그룹 (PR Items에서) + materialGroup: majorItem?.materialCategory ?? null, + materialGroupDesc: majorItem?.materialDescription ?? null, + + // 카운트 + vendorCount, + shortListedVendorCount, + quotationReceivedCount, + prItemsCount: prItemsCount[0]?.count ?? 0, + majorItemsCount: majorItemsCount[0]?.count ?? 0, + + // 감사 정보 + createdBy: rfq.createdBy, + createdByUserName: createdByUser?.name ?? null, + createdAt: rfq.createdAt, + updatedBy: rfq.updatedBy, + updatedByUserName: updatedByUser?.name ?? null, + updatedAt: rfq.updatedAt, + sentBy: rfq.sentBy, + sentByUserName: sentByUser?.name ?? null, + + remark: rfq.remark, + + // 추가 필드 (필요시) + evaluationApply: true, // 기본값 또는 별도 로직 + quotationType: rfq.rfqType ?? undefined, + contractType: undefined, // 별도 필드 필요 + + // 연관 데이터 + vendors: vendorDetails, + attachments: attachments, + }; + + return rfqFullInfo; + } catch (error) { + console.error("RFQ 정보 조회 실패:", error); + throw error; + } +} + +/** + * SendRfqDialog용 간단한 정보 조회 + */ +export async function getRfqInfoForSend(rfqId: number) { + const fullInfo = await getRfqFullInfo(rfqId); + + return { + rfqCode: fullInfo.rfqCode, + rfqTitle: fullInfo.rfqTitle || '', + rfqType: fullInfo.rfqType || '', + projectCode: fullInfo.projectCode, + projectName: fullInfo.projectName, + picName: fullInfo.picName, + picCode: fullInfo.picCode, + picTeam: fullInfo.picTeam, + packageNo: fullInfo.packageNo, + packageName: fullInfo.packageName, + designPicName: fullInfo.engPicName, // EngPicName이 설계담당자 + designTeam: fullInfo.designTeam, + materialGroup: fullInfo.materialGroup, + materialGroupDesc: fullInfo.materialGroupDesc, + dueDate: fullInfo.dueDate || new Date(), + quotationType: fullInfo.quotationType, + evaluationApply: fullInfo.evaluationApply, + contractType: fullInfo.contractType, + }; +} + +/** + * 벤더 정보만 조회 + */ +export async function getRfqVendors(rfqId: number) { + const fullInfo = await getRfqFullInfo(rfqId); + return fullInfo.vendors; +} + +/** + * 첨부파일 정보만 조회 + */ +export async function getRfqAttachments(rfqId: number) { + const fullInfo = await getRfqFullInfo(rfqId); + return fullInfo.attachments; }
\ No newline at end of file diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx index 1b8fa528..7de8cfa4 100644 --- a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -50,11 +50,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; -import { +import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, - getPlaceOfDestinationForSelection + getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service"; interface BatchUpdateConditionsDialogProps { @@ -108,19 +108,19 @@ export function BatchUpdateConditionsDialog({ onSuccess, }: BatchUpdateConditionsDialogProps) { const [isLoading, setIsLoading] = React.useState(false); - + // Select 옵션들 상태 const [incoterms, setIncoterms] = React.useState<SelectOption[]>([]); const [paymentTerms, setPaymentTerms] = React.useState<SelectOption[]>([]); const [shippingPlaces, setShippingPlaces] = React.useState<SelectOption[]>([]); const [destinationPlaces, setDestinationPlaces] = React.useState<SelectOption[]>([]); - + // 로딩 상태 const [incotermsLoading, setIncotermsLoading] = React.useState(false); const [paymentTermsLoading, setPaymentTermsLoading] = React.useState(false); const [shippingLoading, setShippingLoading] = React.useState(false); const [destinationLoading, setDestinationLoading] = React.useState(false); - + // Popover 열림 상태 const [incotermsOpen, setIncotermsOpen] = React.useState(false); const [paymentTermsOpen, setPaymentTermsOpen] = React.useState(false); @@ -254,7 +254,7 @@ export function BatchUpdateConditionsDialog({ // 선택된 필드만 포함하여 conditions 객체 생성 const conditions: any = {}; - + if (fieldsToUpdate.currency && data.currency) { conditions.currency = data.currency; } @@ -372,7 +372,7 @@ export function BatchUpdateConditionsDialog({ <Alert> <Info className="h-4 w-4" /> <AlertDescription> - 체크박스를 선택한 항목만 업데이트됩니다. + 체크박스를 선택한 항목만 업데이트됩니다. 선택하지 않은 항목은 기존 값이 유지됩니다. </AlertDescription> </Alert> @@ -387,7 +387,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.currency} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked }) } /> @@ -419,7 +419,13 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="통화 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {currencies.map((currency) => ( @@ -454,7 +460,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.paymentTermsCode} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked }) } /> @@ -496,7 +502,13 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="코드 또는 설명으로 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {paymentTerms.map((term) => ( @@ -538,7 +550,7 @@ export function BatchUpdateConditionsDialog({ <Checkbox className="mt-3" checked={fieldsToUpdate.incoterms} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked }) } /> @@ -581,7 +593,12 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="코드 또는 설명으로 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }}> <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {incoterms.map((incoterm) => ( @@ -640,7 +657,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.deliveryDate} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked }) } /> @@ -701,7 +718,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.contractDuration} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked }) } /> @@ -736,7 +753,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.taxCode} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked }) } /> @@ -770,7 +787,7 @@ export function BatchUpdateConditionsDialog({ <Checkbox className="mt-3" checked={fieldsToUpdate.shipping} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked }) } /> @@ -813,7 +830,13 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="선적지 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {shippingPlaces.map((place) => ( @@ -848,7 +871,7 @@ export function BatchUpdateConditionsDialog({ </FormItem> )} /> - + <FormField control={form.control} name="placeOfDestination" @@ -887,7 +910,13 @@ export function BatchUpdateConditionsDialog({ <PopoverContent className="w-full p-0" align="start"> <Command> <CommandInput placeholder="도착지 검색..." /> - <CommandList> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> {destinationPlaces.map((place) => ( @@ -937,7 +966,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.materialPrice} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked }) } /> @@ -973,7 +1002,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.sparepart} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked }) } /> @@ -1028,7 +1057,7 @@ export function BatchUpdateConditionsDialog({ <div className="flex items-center gap-4"> <Checkbox checked={fieldsToUpdate.first} - onCheckedChange={(checked) => + onCheckedChange={(checked) => setFieldsToUpdate({ ...fieldsToUpdate, first: !!checked }) } /> @@ -1086,7 +1115,7 @@ export function BatchUpdateConditionsDialog({ <DialogFooter className="p-6 pt-4 border-t"> <div className="flex items-center justify-between w-full"> <div className="text-sm text-muted-foreground"> - {getUpdateCount() > 0 + {getUpdateCount() > 0 ? `${getUpdateCount()}개 항목 선택됨` : '변경할 항목을 선택하세요' } @@ -1100,12 +1129,12 @@ export function BatchUpdateConditionsDialog({ > 취소 </Button> - <Button + <Button type="submit" disabled={isLoading || getUpdateCount() === 0} > {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {getUpdateCount() > 0 + {getUpdateCount() > 0 ? `${getUpdateCount()}개 항목 업데이트` : '조건 업데이트' } diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index b6d42804..7f7afe14 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { +import { Plus, Send, Eye, @@ -32,7 +32,7 @@ import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; import { ClientDataTable } from "@/components/client-data-table/data-table"; -import { +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -50,7 +50,9 @@ import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { AddVendorDialog } from "./add-vendor-dialog"; import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; +import { SendRfqDialog } from "./send-rfq-dialog"; // import { VendorDetailDialog } from "./vendor-detail-dialog"; +// import { sendRfqToVendors } from "@/app/actions/rfq/send-rfq.action"; // 타입 정의 interface RfqDetail { @@ -59,9 +61,10 @@ interface RfqDetail { vendorName: string | null; vendorCode: string | null; vendorCountry: string | null; - vendorCategory?: string | null; // 업체분류 - vendorGrade?: string | null; // AVL 등급 - basicContract?: string | null; // 기본계약 + vendorEmail?: string | null; + vendorCategory?: string | null; + vendorGrade?: string | null; + basicContract?: string | null; shortList: boolean; currency: string | null; paymentTermsCode: string | null; @@ -97,11 +100,42 @@ interface VendorResponse { attachmentCount?: number; } +// Props 타입 정의 (중복 제거하고 하나로 통합) interface RfqVendorTableProps { rfqId: number; rfqCode?: string; rfqDetails: RfqDetail[]; vendorResponses: VendorResponse[]; + // 추가 props + rfqInfo?: { + rfqTitle: string; + rfqType: string; + projectCode?: string; + projectName?: string; + picName?: string; + picCode?: string; + picTeam?: string; + packageNo?: string; + packageName?: string; + designPicName?: string; + designTeam?: string; + materialGroup?: string; + materialGroupDesc?: string; + dueDate: Date; + quotationType?: string; + evaluationApply?: boolean; + contractType?: string; + }; + attachments?: Array<{ + id: number; + attachmentType: string; + serialNo: string; + currentRevision: string; + description?: string; + fileName?: string; + fileSize?: number; + uploadedAt?: Date; + }>; } // 상태별 아이콘 반환 @@ -158,43 +192,94 @@ export function RfqVendorTable({ rfqCode, rfqDetails, vendorResponses, + rfqInfo, + attachments, }: RfqVendorTableProps) { const [isRefreshing, setIsRefreshing] = React.useState(false); const [selectedRows, setSelectedRows] = React.useState<any[]>([]); const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false); const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false); const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); + const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false); // 데이터 병합 const mergedData = React.useMemo( () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), [rfqDetails, vendorResponses, rfqCode] ); - + + // 일괄 발송 핸들러 + const handleBulkSend = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요."); + return; + } + + // 다이얼로그 열기 + setIsSendDialogOpen(true); + }, [selectedRows]); + + // RFQ 발송 핸들러 + const handleSendRfq = React.useCallback(async (data: { + vendors: Array<{ + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + vendorEmail?: string | null; + currency?: string | null; + additionalRecipients: string[]; + }>; + attachments: number[]; + message?: string; + }) => { + try { + // 서버 액션 호출 + // const result = await sendRfqToVendors({ + // rfqId, + // rfqCode, + // vendors: data.vendors, + // attachmentIds: data.attachments, + // message: data.message, + // }); + + // 임시 성공 처리 + console.log("RFQ 발송 데이터:", data); + + // 성공 후 처리 + setSelectedRows([]); + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); + } catch (error) { + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + throw error; + } + }, [rfqId, rfqCode]); + // 액션 처리 const handleAction = React.useCallback(async (action: string, vendor: any) => { switch (action) { case "view": setSelectedVendor(vendor); break; - + case "send": // RFQ 발송 로직 toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`); break; - + case "edit": // 수정 로직 toast.info("수정 기능은 준비중입니다."); break; - + case "delete": // 삭제 로직 if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); } break; - + case "response-detail": // 회신 상세 보기 toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); @@ -202,21 +287,6 @@ export function RfqVendorTable({ } }, []); - // 선택된 벤더들에게 일괄 발송 - const handleBulkSend = React.useCallback(async () => { - if (selectedRows.length === 0) { - toast.warning("발송할 벤더를 선택해주세요."); - return; - } - - const vendorNames = selectedRows.map(r => r.vendorName).join(", "); - if (confirm(`선택한 ${selectedRows.length}개 벤더에게 RFQ를 발송하시겠습니까?\n\n${vendorNames}`)) { - toast.success(`${selectedRows.length}개 벤더에게 RFQ를 발송했습니다.`); - setSelectedRows([]); - } - }, [selectedRows]); - - // 컬럼 정의 (확장된 버전) const columns: ColumnDef<any>[] = React.useMemo(() => [ { @@ -251,19 +321,6 @@ export function RfqVendorTable({ }, size: 120, }, - // { - // accessorKey: "response.responseVersion", - // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Rev" />, - // cell: ({ row }) => { - // const version = row.original.response?.responseVersion; - // return version ? ( - // <Badge variant="outline" className="font-mono">v{version}</Badge> - // ) : ( - // <span className="text-muted-foreground">-</span> - // ); - // }, - // size: 60, - // }, { accessorKey: "vendorName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />, @@ -307,14 +364,14 @@ export function RfqVendorTable({ cell: ({ row }) => { const grade = row.original.vendorGrade; if (!grade) return <span className="text-muted-foreground">-</span>; - + const gradeColor = { "A": "text-green-600", - "B": "text-blue-600", + "B": "text-blue-600", "C": "text-yellow-600", "D": "text-red-600", }[grade] || "text-gray-600"; - + return <span className={cn("font-semibold", gradeColor)}>{grade}</span>; }, size: 100, @@ -373,15 +430,15 @@ export function RfqVendorTable({ cell: ({ row }) => { const deliveryDate = row.original.deliveryDate; const contractDuration = row.original.contractDuration; - + return ( <div className="flex flex-col gap-0.5"> - {deliveryDate && ( + {deliveryDate && !rfqCode?.startsWith("F") && ( <span className="text-xs"> {format(new Date(deliveryDate), "yyyy-MM-dd")} </span> )} - {contractDuration && ( + {contractDuration && rfqCode?.startsWith("F") && ( <span className="text-xs text-muted-foreground">{contractDuration}</span> )} {!deliveryDate && !contractDuration && ( @@ -398,7 +455,7 @@ export function RfqVendorTable({ cell: ({ row }) => { const code = row.original.incotermsCode; const detail = row.original.incotermsDetail; - + return ( <TooltipProvider> <Tooltip> @@ -459,7 +516,7 @@ export function RfqVendorTable({ if (conditions === "-") { return <span className="text-muted-foreground">-</span>; } - + const items = conditions.split(", "); return ( <div className="flex flex-wrap gap-1"> @@ -479,11 +536,11 @@ export function RfqVendorTable({ cell: ({ row }) => { const submittedAt = row.original.response?.submittedAt; const status = row.original.response?.status; - + if (!submittedAt) { return <Badge variant="outline">미참여</Badge>; } - + return ( <div className="flex flex-col gap-0.5"> <Badge variant="default" className="text-xs">참여</Badge> @@ -500,11 +557,11 @@ export function RfqVendorTable({ header: "회신상세", cell: ({ row }) => { const hasResponse = !!row.original.response?.submittedAt; - + if (!hasResponse) { return <span className="text-muted-foreground text-xs">-</span>; } - + return ( <Button variant="ghost" @@ -565,7 +622,7 @@ export function RfqVendorTable({ cell: ({ row }) => { const vendor = row.original; const hasResponse = !!vendor.response; - + return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -592,7 +649,7 @@ export function RfqVendorTable({ 조건 수정 </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem + <DropdownMenuItem onClick={() => handleAction("delete", vendor)} className="text-red-600" > @@ -605,7 +662,7 @@ export function RfqVendorTable({ }, size: 60, }, - ], [handleAction]); + ], [handleAction, rfqCode]); const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ { id: "vendorName", label: "벤더명", type: "text" }, @@ -644,6 +701,41 @@ export function RfqVendorTable({ })); }, [selectedRows]); + // 선택된 벤더 정보 (Send용) + const selectedVendorsForSend = React.useMemo(() => { + return selectedRows.map(row => ({ + vendorId: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + vendorCountry: row.vendorCountry, + vendorEmail: row.vendorEmail || `vendor${row.vendorId}@example.com`, + currency: row.currency, + })); + }, [selectedRows]); + + // RFQ 정보 준비 (다이얼로그용) + const rfqInfoForDialog = React.useMemo(() => { + // props로 받은 rfqInfo 사용, 없으면 기본값 + return rfqInfo || { + rfqCode: rfqCode || '', + rfqTitle: '테스트 RFQ', + rfqType: '정기견적', + projectCode: 'PN003', + projectName: 'PETRONAS ZLNG nearshore project', + picName: '김*종', + picCode: '86D', + picTeam: '해양구매팀(해양구매1)', + packageNo: 'MM03', + packageName: 'Deck Machinery', + designPicName: '이*진', + designTeam: '전장설계팀 (전장기기시스템)', + materialGroup: 'BE2101', + materialGroupDesc: 'Combined Windlass & Mooring Wi', + dueDate: new Date('2025-07-05'), + evaluationApply: true, + }; + }, [rfqInfo, rfqCode]); + // 추가 액션 버튼들 const additionalActions = React.useMemo(() => ( <div className="flex items-center gap-2"> @@ -732,6 +824,16 @@ export function RfqVendorTable({ }} /> + {/* RFQ 발송 다이얼로그 */} + <SendRfqDialog + open={isSendDialogOpen} + onOpenChange={setIsSendDialogOpen} + selectedVendors={selectedVendorsForSend} + rfqInfo={rfqInfoForDialog} + attachments={attachments || []} + onSend={handleSendRfq} + /> + {/* 벤더 상세 다이얼로그 */} {/* {selectedVendor && ( <VendorDetailDialog diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx new file mode 100644 index 00000000..dc420cad --- /dev/null +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -0,0 +1,578 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Send, + Building2, + User, + Calendar, + Package, + FileText, + Plus, + X, + Paperclip, + Download, + Mail, + Users, + AlertCircle, + Info, + File, + CheckCircle, + RefreshCw +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Alert, + AlertDescription, +} from "@/components/ui/alert"; + +// 타입 정의 +interface Vendor { + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + vendorEmail?: string | null; + currency?: string | null; +} + +interface Attachment { + id: number; + attachmentType: string; + serialNo: string; + currentRevision: string; + description?: string; + fileName?: string; + fileSize?: number; + uploadedAt?: Date; +} + +interface RfqInfo { + rfqCode: string; + rfqTitle: string; + rfqType: string; + projectCode?: string; + projectName?: string; + picName?: string; + picCode?: string; + picTeam?: string; + packageNo?: string; + packageName?: string; + designPicName?: string; + designTeam?: string; + materialGroup?: string; + materialGroupDesc?: string; + dueDate: Date; + quotationType?: string; + evaluationApply?: boolean; + contractType?: string; +} + +interface VendorWithRecipients extends Vendor { + additionalRecipients: string[]; +} + +interface SendRfqDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedVendors: Vendor[]; + rfqInfo: RfqInfo; + attachments?: Attachment[]; + onSend: (data: { + vendors: VendorWithRecipients[]; + attachments: number[]; + message?: string; + }) => Promise<void>; +} + +// 첨부파일 타입별 아이콘 +const getAttachmentIcon = (type: string) => { + switch (type.toLowerCase()) { + case "technical": + return <FileText className="h-4 w-4 text-blue-500" />; + case "commercial": + return <File className="h-4 w-4 text-green-500" />; + case "drawing": + return <Package className="h-4 w-4 text-purple-500" />; + default: + return <Paperclip className="h-4 w-4 text-gray-500" />; + } +}; + +// 파일 크기 포맷 +const formatFileSize = (bytes?: number) => { + if (!bytes) return "0 KB"; + const kb = bytes / 1024; + const mb = kb / 1024; + if (mb >= 1) return `${mb.toFixed(2)} MB`; + return `${kb.toFixed(2)} KB`; +}; + +export function SendRfqDialog({ + open, + onOpenChange, + selectedVendors, + rfqInfo, + attachments = [], + onSend, +}: SendRfqDialogProps) { + const [isSending, setIsSending] = React.useState(false); + const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]); + const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]); + const [additionalMessage, setAdditionalMessage] = React.useState(""); + + // 초기화 + React.useEffect(() => { + if (open && selectedVendors.length > 0) { + setVendorsWithRecipients( + selectedVendors.map(v => ({ + ...v, + additionalRecipients: [] + })) + ); + // 모든 첨부파일 선택 + setSelectedAttachments(attachments.map(a => a.id)); + } + }, [open, selectedVendors, attachments]); + + // 추가 수신처 이메일 추가 + const handleAddRecipient = (vendorId: number, email: string) => { + if (!email) return; + + // 이메일 유효성 검사 + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + toast.error("올바른 이메일 형식이 아닙니다."); + return; + } + + setVendorsWithRecipients(prev => + prev.map(v => + v.vendorId === vendorId + ? { ...v, additionalRecipients: [...v.additionalRecipients, email] } + : v + ) + ); + }; + + // 추가 수신처 이메일 제거 + const handleRemoveRecipient = (vendorId: number, index: number) => { + setVendorsWithRecipients(prev => + prev.map(v => + v.vendorId === vendorId + ? { + ...v, + additionalRecipients: v.additionalRecipients.filter((_, i) => i !== index) + } + : v + ) + ); + }; + + // 첨부파일 선택 토글 + const toggleAttachment = (attachmentId: number) => { + setSelectedAttachments(prev => + prev.includes(attachmentId) + ? prev.filter(id => id !== attachmentId) + : [...prev, attachmentId] + ); + }; + + // 전송 처리 + const handleSend = async () => { + try { + setIsSending(true); + + // 유효성 검사 + if (selectedAttachments.length === 0) { + toast.warning("최소 하나 이상의 첨부파일을 선택해주세요."); + return; + } + + await onSend({ + vendors: vendorsWithRecipients, + attachments: selectedAttachments, + message: additionalMessage, + }); + + toast.success(`${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.`); + onOpenChange(false); + } catch (error) { + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + } finally { + setIsSending(false); + } + }; + + // 총 수신자 수 계산 + const totalRecipientCount = React.useMemo(() => { + return vendorsWithRecipients.reduce((acc, v) => + acc + 1 + v.additionalRecipients.length, 0 + ); + }, [vendorsWithRecipients]); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Send className="h-5 w-5" /> + RFQ 일괄 발송 + </DialogTitle> + <DialogDescription> + 선택한 {selectedVendors.length}개 업체에 RFQ를 발송합니다. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="flex-1 max-h-[calc(90vh-200px)]"> + <div className="space-y-6 pr-4"> + {/* RFQ 정보 섹션 */} + <div className="space-y-4"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Info className="h-4 w-4" /> + RFQ 정보 + </div> + + <div className="bg-muted/50 rounded-lg p-4 space-y-3"> + {/* 프로젝트 정보 */} + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">프로젝트:</span> + <span className="font-medium"> + {rfqInfo.projectCode || "PN003"} ({rfqInfo.projectName || "PETRONAS ZLNG nearshore project"}) + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">견적번호:</span> + <span className="font-medium font-mono">{rfqInfo.rfqCode}</span> + </div> + </div> + + {/* 담당자 정보 */} + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">구매담당:</span> + <span> + {rfqInfo.picName || "김*종"} ({rfqInfo.picCode || "86D"}) {rfqInfo.picTeam || "해양구매팀(해양구매1)"} + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">설계담당:</span> + <span> + {rfqInfo.designPicName || "이*진"} {rfqInfo.designTeam || "전장설계팀 (전장기기시스템)"} + </span> + </div> + </div> + + {/* PKG 및 자재 정보 */} + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">PKG 정보:</span> + <span> + {rfqInfo.packageNo || "MM03"} ({rfqInfo.packageName || "Deck Machinery"}) + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">자재그룹:</span> + <span> + {rfqInfo.materialGroup || "BE2101"} ({rfqInfo.materialGroupDesc || "Combined Windlass & Mooring Wi"}) + </span> + </div> + </div> + + {/* 견적 정보 */} + <div className="grid grid-cols-2 gap-4 text-sm"> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">견적마감일:</span> + <span className="font-medium text-red-600"> + {format(rfqInfo.dueDate, "yyyy.MM.dd", { locale: ko })} + </span> + </div> + <div className="flex items-start gap-2"> + <span className="text-muted-foreground min-w-[80px]">평가적용:</span> + <Badge variant={rfqInfo.evaluationApply ? "default" : "outline"}> + {rfqInfo.evaluationApply ? "Y" : "N"} + </Badge> + </div> + </div> + + {/* 견적명 */} + <div className="flex items-start gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">견적명:</span> + <span className="font-medium">{rfqInfo.rfqTitle}</span> + </div> + + {/* 계약구분 (일반견적일 때만) */} + {rfqInfo.rfqType === "일반견적" && ( + <div className="flex items-start gap-2 text-sm"> + <span className="text-muted-foreground min-w-[80px]">계약구분:</span> + <span>{rfqInfo.contractType || "-"}</span> + </div> + )} + </div> + </div> + + <Separator /> + + {/* 첨부파일 섹션 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Paperclip className="h-4 w-4" /> + 첨부파일 ({selectedAttachments.length}/{attachments.length}) + </div> + <Button + variant="ghost" + size="sm" + onClick={() => { + if (selectedAttachments.length === attachments.length) { + setSelectedAttachments([]); + } else { + setSelectedAttachments(attachments.map(a => a.id)); + } + }} + > + {selectedAttachments.length === attachments.length ? "전체 해제" : "전체 선택"} + </Button> + </div> + + <div className="border rounded-lg divide-y"> + {attachments.length > 0 ? ( + attachments.map((attachment) => ( + <div + key={attachment.id} + className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors" + > + <div className="flex items-center gap-3"> + <Checkbox + checked={selectedAttachments.includes(attachment.id)} + onCheckedChange={() => toggleAttachment(attachment.id)} + /> + {getAttachmentIcon(attachment.attachmentType)} + <div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium"> + {attachment.fileName || `${attachment.attachmentType}_${attachment.serialNo}`} + </span> + <Badge variant="outline" className="text-xs"> + {attachment.currentRevision} + </Badge> + </div> + {attachment.description && ( + <p className="text-xs text-muted-foreground mt-0.5"> + {attachment.description} + </p> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground"> + {formatFileSize(attachment.fileSize)} + </span> + </div> + </div> + )) + ) : ( + <div className="p-8 text-center text-muted-foreground"> + <Paperclip className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">첨부파일이 없습니다.</p> + </div> + )} + </div> + </div> + + <Separator /> + + {/* 수신 업체 섹션 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Building2 className="h-4 w-4" /> + 수신 업체 ({selectedVendors.length}) + </div> + <Badge variant="outline" className="flex items-center gap-1"> + <Users className="h-3 w-3" /> + 총 {totalRecipientCount}명 + </Badge> + </div> + + <div className="space-y-3"> + {vendorsWithRecipients.map((vendor, index) => ( + <div + key={vendor.vendorId} + className="border rounded-lg p-4 space-y-3" + > + {/* 업체 정보 */} + <div className="flex items-start justify-between"> + <div className="flex items-center gap-3"> + <div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary text-sm font-medium"> + {index + 1} + </div> + <div> + <div className="flex items-center gap-2"> + <span className="font-medium">{vendor.vendorName}</span> + <Badge variant="outline" className="text-xs"> + {vendor.vendorCountry} + </Badge> + </div> + {vendor.vendorCode && ( + <span className="text-xs text-muted-foreground"> + {vendor.vendorCode} + </span> + )} + </div> + </div> + <Badge variant="secondary"> + 주 수신: {vendor.vendorEmail || "vendor@example.com"} + </Badge> + </div> + + {/* 추가 수신처 */} + <div className="pl-11 space-y-2"> + <div className="flex items-center gap-2"> + <Label className="text-xs text-muted-foreground">추가 수신처:</Label> + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <AlertCircle className="h-3 w-3 text-muted-foreground" /> + </TooltipTrigger> + <TooltipContent> + <p>참조로 RFQ를 받을 추가 이메일 주소를 입력하세요.</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + + {/* 추가된 이메일 목록 */} + <div className="flex flex-wrap gap-2"> + {vendor.additionalRecipients.map((email, idx) => ( + <Badge + key={idx} + variant="outline" + className="flex items-center gap-1 pr-1" + > + <Mail className="h-3 w-3" /> + {email} + <Button + variant="ghost" + size="sm" + className="h-4 w-4 p-0 hover:bg-transparent" + onClick={() => handleRemoveRecipient(vendor.vendorId, idx)} + > + <X className="h-3 w-3" /> + </Button> + </Badge> + ))} + </div> + + {/* 이메일 입력 필드 */} + <div className="flex gap-2"> + <Input + type="email" + placeholder="추가 수신자 이메일 입력" + className="h-8 text-sm" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const input = e.target as HTMLInputElement; + handleAddRecipient(vendor.vendorId, input.value); + input.value = ""; + } + }} + /> + <Button + variant="outline" + size="sm" + onClick={(e) => { + const input = (e.currentTarget.previousElementSibling as HTMLInputElement); + handleAddRecipient(vendor.vendorId, input.value); + input.value = ""; + }} + > + <Plus className="h-4 w-4" /> + </Button> + </div> + </div> + </div> + ))} + </div> + </div> + + <Separator /> + + {/* 추가 메시지 (선택사항) */} + <div className="space-y-2"> + <Label htmlFor="message" className="text-sm font-medium"> + 추가 메시지 (선택사항) + </Label> + <textarea + id="message" + className="w-full min-h-[80px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary" + placeholder="업체에 전달할 추가 메시지를 입력하세요..." + value={additionalMessage} + onChange={(e) => setAdditionalMessage(e.target.value)} + /> + </div> + </div> + </ScrollArea> + + <DialogFooter className="flex-shrink-0"> + <Alert className="mr-auto max-w-md"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription className="text-xs"> + 발송 후에는 취소할 수 없습니다. 발송 내용을 다시 한번 확인해주세요. + </AlertDescription> + </Alert> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSending} + > + 취소 + </Button> + <Button + onClick={handleSend} + disabled={isSending || selectedAttachments.length === 0} + > + {isSending ? ( + <> + <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> + 발송중... + </> + ) : ( + <> + <Send className="h-4 w-4 mr-2" /> + RFQ 발송 + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file |
