diff options
Diffstat (limited to 'lib/rfq-last')
| -rw-r--r-- | lib/rfq-last/service.ts | 461 | ||||
| -rw-r--r-- | lib/rfq-last/table/create-general-rfq-dialog.tsx | 32 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-filter-sheet.tsx | 16 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-items-dialog.tsx | 45 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table.tsx | 17 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/attachments-upload.tsx | 134 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx | 50 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx | 2 | ||||
| -rw-r--r-- | lib/rfq-last/vendor-response/rfq-items-dialog.tsx | 46 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/batch-update-conditions-dialog.tsx | 2 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 2 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 384 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/vendor-detail-dialog.tsx | 2 |
13 files changed, 1064 insertions, 129 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index f2894577..2baf1f46 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 { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews } from "@/db/schema"; +import { avlVendorInfo, paymentTerms, incoterms, rfqLastVendorQuotationItems, rfqLastVendorAttachments, rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView, vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts, projects, basicContract, basicContractTemplates, rfqLastTbeSessions, rfqLastTbeDocumentReviews, templateDetailView } 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"; @@ -65,7 +65,11 @@ export async function getRfqs(input: GetRfqsSchema) { if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) { console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`)); - + // dueDate 필터 디버깅 + const dueDateFilters = input.filters.filter(f => f.id === 'dueDate'); + if (dueDateFilters.length > 0) { + console.log("dueDate 필터 상세:", dueDateFilters); + } try { advancedWhere = filterColumns({ table: rfqsLastView, @@ -74,6 +78,10 @@ export async function getRfqs(input: GetRfqsSchema) { }); console.log("필터 조건 생성 완료"); + // dueDate 필터가 포함된 경우 SQL 쿼리 확인 + if (dueDateFilters.length > 0) { + console.log("advancedWhere SQL:", advancedWhere); + } } catch (error) { console.error("필터 조건 생성 오류:", error); advancedWhere = undefined; @@ -313,6 +321,7 @@ interface CreateGeneralRfqInput { rfqTitle: string; dueDate: Date; picUserId: number; + projectId?: number; remark?: string; items: Array<{ itemCode: string; @@ -371,6 +380,9 @@ export async function createGeneralRfqAction(input: CreateGeneralRfqInput) { status: "RFQ 생성", dueDate: dueDate, // 마감일 기본값 설정 + // 프로젝트 정보 (선택사항) + projectId: input.projectId || null, + // 대표 아이템 정보 itemCode: representativeItem.itemCode, itemName: representativeItem.itemName, @@ -393,8 +405,8 @@ export async function createGeneralRfqAction(input: CreateGeneralRfqInput) { const prItemsData = input.items.map((item, index) => ({ rfqsLastId: newRfq.id, rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ... - prItem: `${index + 1}`.padStart(3, '0'), - prNo: rfqCode, // RFQ 코드를 PR 번호로 사용 + prItem: null, // 일반견적에서는 PR 아이템 번호를 null로 설정 + prNo: null, // 일반견적에서는 PR 번호를 null로 설정 materialCode: item.itemCode, materialDescription: item.itemName, @@ -2469,7 +2481,46 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { throw error; } } +/** + * RFQ 발송용 이메일 템플릿 자동 선택 + */ +export async function getRfqEmailTemplate(): Promise<{ slug: string; name: string; category: string } | null> { + try { + // 1. 템플릿 목록 조회 + const templates = await db + .select({ + slug: templateDetailView.slug, + name: templateDetailView.name, + category: templateDetailView.category, + isActive: templateDetailView.isActive, + }) + .from(templateDetailView) + .where(eq(templateDetailView.isActive, true)) + .orderBy(templateDetailView.name); + + // 2. RFQ 또는 견적 관련 템플릿 찾기 (우선순위: category > name) + let selectedTemplate = null; + + // 우선 category가 'rfq' 또는 'quotation'인 템플릿 찾기 + selectedTemplate = templates.find(t => + t.category === 'rfq' || t.category === 'quotation' + ); + + // 없으면 이름에 '견적' 또는 'rfq'가 포함된 템플릿 찾기 + if (!selectedTemplate) { + selectedTemplate = templates.find(t => + t.name.toLowerCase().includes('견적') || + t.name.toLowerCase().includes('rfq') || + t.name.toLowerCase().includes('quotation') + ); + } + return selectedTemplate || null; + } catch (error) { + console.error("RFQ 이메일 템플릿 조회 실패:", error); + return null; + } +} /** * SendRfqDialog용 간단한 정보 조회 */ @@ -2646,6 +2697,14 @@ export async function getRfqSendData(rfqId: number): Promise<RfqSendData> { quotationType: rfq.rfqType || undefined, evaluationApply: true, // 기본값 또는 별도 필드 contractType: undefined, // 필요시 추가 + // 시스템 정보 + formattedDueDate: rfq.dueDate ? rfq.dueDate.toLocaleDateString('ko-KR') : undefined, + systemName: "SHI EVCP", + hasAttachments: attachments.length > 0, + attachmentsCount: attachments.length, + language: "ko", + companyName: "삼성중공업", + now: new Date(), }; return { @@ -2857,6 +2916,12 @@ export interface SendRfqParams { vendors: VendorForSend[]; attachmentIds: number[]; message?: string; + generatedPdfs?: Array<{ + key: string; + buffer: number[]; + fileName: string; + }>; + hasToSendEmail?: boolean; // 이메일 발송 여부 } export async function sendRfqToVendors({ @@ -2865,14 +2930,9 @@ export async function sendRfqToVendors({ vendors, attachmentIds, message, - generatedPdfs -}: SendRfqParams & { - generatedPdfs?: Array<{ - key: string; - buffer: number[]; - fileName: string; - }>; -}) { + generatedPdfs, + hasToSendEmail = true +}: SendRfqParams) { const session = await getServerSession(authOptions); if (!session?.user) { throw new Error("인증이 필요합니다."); @@ -2909,7 +2969,8 @@ export async function sendRfqToVendors({ picInfo, emailAttachments, designAttachments, - generatedPdfs + generatedPdfs, + hasToSendEmail }); // 6. RFQ 상태 업데이트 @@ -3093,7 +3154,8 @@ async function processVendors({ picInfo, emailAttachments, designAttachments, - generatedPdfs + generatedPdfs, + hasToSendEmail }: { rfqId: number; rfqData: any; @@ -3103,6 +3165,7 @@ async function processVendors({ emailAttachments: any[]; designAttachments: any[]; generatedPdfs?: any[]; + hasToSendEmail?: boolean; }) { const results = []; const errors = []; @@ -3130,7 +3193,8 @@ async function processVendors({ picInfo, contractsDir, generatedPdfs, - designAttachments + designAttachments, + hasToSendEmail }); }); @@ -3170,7 +3234,8 @@ async function processSingleVendor({ picInfo, contractsDir, generatedPdfs, - designAttachments + designAttachments, + hasToSendEmail }: any) { const isResend = vendor.isResend || false; const sendVersion = (vendor.sendVersion || 0) + 1; @@ -3218,6 +3283,19 @@ async function processSingleVendor({ currentUser, designAttachments }); + // 이메일 발송 처리 (사용자가 선택한 경우에만) + let emailSent = null; + if (hasToSendEmail) { + emailSent = await handleRfqSendEmail({ + tx, + rfqId, + rfqData, + vendor, + newRfqDetail, + currentUser, + picInfo + }); + } return { result: { @@ -3227,7 +3305,8 @@ async function processSingleVendor({ responseId: vendorResponse.id, isResend, sendVersion, - tbeSessionCreated: tbeSession + tbeSessionCreated: tbeSession, + emailSent }, contracts, tbeSession @@ -3683,7 +3762,289 @@ async function updateRfqStatus(rfqId: number, userId: number) { }) .where(eq(rfqsLast.id, rfqId)); } +async function handleRfqSendEmail({ + tx, + rfqId, + rfqData, + vendor, + newRfqDetail, + currentUser, + picInfo +}: any) { + try { + // 1. 이메일 수신자 정보 준비 + const emailRecipients = prepareEmailRecipients(vendor, picInfo.picEmail); + + // 2. RFQ 기본 정보 조회 (템플릿용) + const rfqBasicInfoResult = await getRfqBasicInfoAction(rfqId); + const rfqBasicInfo = rfqBasicInfoResult.success ? rfqBasicInfoResult.data : null; + + // 3. 프로젝트 정보 조회 + let projectInfo = null; + if (rfqData.projectId) { + projectInfo = await getProjectInfo(rfqData.projectId); + } + + // 4. PR Items 정보 조회 (주요 품목) + const [majorItem] = await tx + .select({ + materialCategory: rfqPrItems.materialCategory, + materialDescription: rfqPrItems.materialDescription, + prNo: rfqPrItems.prNo, + }) + .from(rfqPrItems) + .where(and( + eq(rfqPrItems.rfqsLastId, rfqId), + eq(rfqPrItems.majorYn, true) + )) + .limit(1); + + // 5. RFQ 첨부파일 조회 + const rfqAttachments = await tx + .select({ + attachment: rfqLastAttachments, + revision: rfqLastAttachmentRevisions + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .where(eq(rfqLastAttachments.rfqId, rfqId)); + + // 6. 이메일 제목 생성 (RFQ 타입에 따라) + const emailSubject = generateEmailSubject({ + rfqType: rfqData.rfqType, + projectName: projectInfo?.name || '', + rfqCode: rfqData.rfqCode, + packageName: rfqData.packageName || '', + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode + }); + + // 7. 이메일 본문용 컨텍스트 데이터 구성 + const emailContext = { + // 기본 정보 + language: "ko", + now: new Date(), + companyName: "삼성중공업", + siteName: "EVCP Portal", + + // RFQ 정보 + rfqId: rfqData.id, + rfqCode: rfqData.rfqCode, + rfqTitle: rfqData.rfqTitle, + rfqType: rfqData.rfqType, + dueDate: rfqData.dueDate, + rfqDescription: rfqData.rfqTitle || `${rfqData.rfqCode} 견적 요청`, + + // 프로젝트 정보 + projectId: rfqData.projectId, + projectCode: projectInfo?.code || '', + projectName: projectInfo?.name || '', + projectCompany: projectInfo?.customerName || '', + projectFlag: projectInfo?.flag || '', + projectSite: projectInfo?.site || '', + + // 패키지 정보 + packageNo: rfqData.packageNo || "MM03", + packageName: rfqData.packageName || "Deck Machinery", + packageDescription: `${rfqData.packageNo || 'MM03'} - ${rfqData.packageName || 'Deck Machinery'}`, + + // 품목 정보 + itemCode: rfqData.itemCode || '', + itemName: rfqData.itemName || '', + itemCount: 1, + materialGroup: majorItem?.materialCategory || "BE2101", + materialGroupDesc: majorItem?.materialDescription || "Combined Windlass & Mooring Winch", + + // 보증 정보 (기본값) + warrantyMonths: "35", + warrantyDescription: "선박 인도 후 35개월 시점까지 납품한 자재 또는 용역이 계약 내용과 동일함을 보증", + repairAdditionalMonths: "24", + repairDescription: "Repair 시 24개월 추가", + totalWarrantyMonths: "36", + totalWarrantyDescription: "총 인도 후 36개월을 넘지 않음", + + // 필수 제출 정보 + requiredDocuments: [ + "품목별 단가 및 중량", + "가격 기재/미기재 견적서(Priced/Unpriced Quotation)", + "설계 Technical Bid Evaluation(TBE) 자료", + "당사 PGS, SGS & POS에 대한 Deviation List" + ], + + // 계약 요구사항 + contractRequirements: { + hasNda: newRfqDetail.ndaYn, + hasGeneralGtc: newRfqDetail.generalGtcYn, + hasProjectGtc: newRfqDetail.projectGtcYn, + hasAgreement: newRfqDetail.agreementYn, + ndaDescription: "비밀유지계약서", + generalGtcDescription: "General GTC", + projectGtcDescription: "Project GTC", + agreementDescription: "기술자료 제공 동의서" + }, + + // 업체 정보 + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + vendorCountry: vendor.vendorCountry, + vendorEmail: vendor.vendorEmail, + vendorRepresentativeEmail: vendor.representativeEmail, + vendorCurrency: vendor.currency, + + // 담당자 정보 + picId: rfqData.picId, + picName: rfqData.picName, + picCode: rfqData.picCode, + picEmail: picInfo.picEmail, + picTeam: rfqData.picTeam, + engPicName: rfqData.EngPicName, + + // PR 정보 + prNumber: rfqData.prNumber, + prIssueDate: rfqData.prIssueDate, + prItemsCount: 1, + + // 시리즈 및 코드 정보 + series: rfqData.series, + smCode: rfqData.smCode, + + // 첨부파일 정보 + attachmentsCount: rfqAttachments.length, + hasAttachments: rfqAttachments.length > 0, + + // 설정 정보 + isDevelopment: process.env.NODE_ENV === 'development', + portalUrl: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', + systemName: "EVCP (Electronic Vendor Communication Portal)", + + // 추가 정보 + currentDate: new Date().toLocaleDateString('ko-KR'), + currentTime: new Date().toLocaleTimeString('ko-KR'), + formattedDueDate: new Date(rfqData.dueDate).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long' + }) + }; + + // 8. 이메일 첨부파일 준비 + const emailAttachmentsList: Array<{ filename: string; content?: Buffer; path?: string }> = []; + + // RFQ 첨부파일 추가 + for (const { attachment, revision } of rfqAttachments) { + if (revision?.filePath) { + try { + const isProduction = process.env.NODE_ENV === "production"; + const cleanPath = revision.filePath.startsWith('/api/files') + ? revision.filePath.slice('/api/files'.length) + : revision.filePath; + + const fullPath = !isProduction + ? path.join(process.cwd(), `public`, cleanPath) + : path.join(`${process.env.NAS_PATH}`, cleanPath); + + const fileBuffer = await fs.readFile(fullPath); + emailAttachmentsList.push({ + filename: revision.originalFileName || `${attachment.attachmentType}_${attachment.serialNo}`, + content: fileBuffer + }); + } catch (error) { + console.error(`이메일 첨부파일 읽기 실패: ${cleanPath}`, error); + } + } + } + + // 9. 이메일 발송 + if (emailRecipients.to.length > 0) { + const isDevelopment = process.env.NODE_ENV === 'development'; + + await sendEmail({ + from: isDevelopment + ? (process.env.Email_From_Address ?? "no-reply@company.com") + : `"${picInfo.picName}" <${picInfo.picEmail}>`, + to: emailRecipients.to.join(", "), + cc: emailRecipients.cc.length > 0 ? emailRecipients.cc.join(", ") : undefined, + subject: emailSubject, + template: "custom-rfq-invitation", + context: emailContext, + attachments: emailAttachmentsList.length > 0 ? emailAttachmentsList : undefined, + }); + + // 10. 이메일 발송 상태 업데이트 + await tx + .update(rfqLastDetails) + .set({ + emailSentAt: new Date(), + emailSentTo: JSON.stringify(emailRecipients), + emailStatus: "sent", + lastEmailSentAt: new Date(), + emailResentCount: newRfqDetail.emailResentCount || 0, + updatedAt: new Date() + }) + .where(eq(rfqLastDetails.id, newRfqDetail.id)); + + return { + success: true, + recipients: emailRecipients.to.length, + ccCount: emailRecipients.cc.length + }; + } + + return { + success: false, + error: "수신자 정보가 없습니다" + }; + + } catch (error) { + console.error(`이메일 발송 실패 (${vendor.vendorName}):`, error); + + // 이메일 발송 실패 상태 업데이트 + await tx + .update(rfqLastDetails) + .set({ + emailStatus: "failed", + updatedAt: new Date() + }) + .where(eq(rfqLastDetails.id, newRfqDetail.id)); + + return { + success: false, + error: error instanceof Error ? error.message : "이메일 발송 실패" + }; + } +} + +// 이메일 제목 생성 함수 +function generateEmailSubject({ + rfqType, + projectName, + rfqCode, + packageName, + vendorName, + vendorCode +}: { + rfqType?: string; + projectName: string; + rfqCode: string; + packageName: string; + vendorName: string; + vendorCode?: string | null; +}) { + const typePrefix = rfqType === 'ITB' ? 'ITB' : + rfqType === 'RFQ' ? 'RFQ' : '일반견적'; + const vendorInfo = vendorCode ? `${vendorName} (${vendorCode})` : vendorName; + + return `[SHI ${typePrefix}] ${projectName} _ ${rfqCode} _ ${packageName} _ ${vendorInfo}`.trim(); +} export async function updateRfqDueDate( rfqId: number, newDueDate: Date | string, @@ -3954,7 +4315,73 @@ export async function updateRfqDueDate( } } +/** + * RFQ 벤더 응답 첨부파일 삭제 + */ +export async function deleteVendorResponseAttachment({ + attachmentId, + responseId, + userId +}: { + attachmentId: number; + responseId: number; + userId: number; +}) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다."); + } + + // 첨부파일이 해당 응답에 속하는지 확인 + const [attachment] = await db + .select() + .from(rfqLastVendorAttachments) + .where( + and( + eq(rfqLastVendorAttachments.id, attachmentId), + eq(rfqLastVendorAttachments.vendorResponseId, responseId) + ) + ) + .limit(1); + + if (!attachment) { + throw new Error("삭제할 첨부파일을 찾을 수 없습니다."); + } + + // 트랜잭션으로 삭제 + await db.transaction(async (tx) => { + // 첨부파일 삭제 + await tx + .delete(rfqLastVendorAttachments) + .where(eq(rfqLastVendorAttachments.id, attachmentId)); + + // 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: responseId, + action: "첨부파일삭제", + changeDetails: { + attachmentId, + attachmentType: attachment.attachmentType, + documentNo: attachment.documentNo, + fileName: attachment.fileName + }, + performedBy: userId, + }); + }); + return { + success: true, + message: "첨부파일이 삭제되었습니다." + }; + } catch (error) { + console.error("첨부파일 삭제 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다." + }; + } +} export async function deleteRfqVendor({ rfqId, detailId, diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index 023c9f2a..1d369648 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -57,6 +57,7 @@ import { toast } from "sonner" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } from "../service" +import { ProjectSelector } from "@/components/ProjectSelector" // 아이템 스키마 const itemSchema = z.object({ @@ -75,6 +76,7 @@ const createGeneralRfqSchema = z.object({ required_error: "제출마감일을 선택해주세요", }), picUserId: z.number().min(1, "견적담당자를 선택해주세요"), + projectId: z.number().optional(), remark: z.string().optional(), items: z.array(itemSchema).min(1, "최소 하나의 자재를 추가해주세요"), }) @@ -114,6 +116,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp rfqTitle: "", dueDate: undefined, picUserId: userId || undefined, + projectId: undefined, remark: "", items: [ { @@ -223,6 +226,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp rfqTitle: "", dueDate: undefined, picUserId: userId || undefined, + projectId: undefined, remark: "", items: [ { @@ -261,8 +265,15 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp rfqTitle: data.rfqTitle, dueDate: data.dueDate, picUserId: data.picUserId, + projectId: data.projectId, remark: data.remark || "", - items: data.items, + items: data.items as Array<{ + itemCode: string; + itemName: string; + quantity: number; + uom: string; + remark?: string; + }>, createdBy: userId, updatedBy: userId, }) @@ -433,6 +444,25 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp )} /> + {/* 프로젝트 선택 */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>프로젝트</FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={(project) => field.onChange(project.id)} + placeholder="프로젝트 선택 (선택사항)..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* 구매 담당자 - 검색 가능한 셀렉터로 변경 */} <FormField control={form.control} diff --git a/lib/rfq-last/table/rfq-filter-sheet.tsx b/lib/rfq-last/table/rfq-filter-sheet.tsx index b88c5d2a..c0b6c0e2 100644 --- a/lib/rfq-last/table/rfq-filter-sheet.tsx +++ b/lib/rfq-last/table/rfq-filter-sheet.tsx @@ -405,11 +405,11 @@ export function RfqFilterSheet({ name="rfqCode" render={({ field }) => ( <FormItem> - <FormLabel>RFQ 코드</FormLabel> + <FormLabel>견적 No.</FormLabel> <FormControl> <div className="relative"> <Input - placeholder="RFQ 코드 입력" + placeholder="견적 No. 입력" {...field} disabled={isPending} className={cn(field.value && "pr-8", "bg-white")} @@ -433,13 +433,13 @@ export function RfqFilterSheet({ )} /> - {/* 상태 */} + {/* 견적상태 */} <FormField control={form.control} name="status" render={({ field }) => ( <FormItem> - <FormLabel>상태</FormLabel> + <FormLabel>견적상태</FormLabel> <Select value={field.value} onValueChange={field.onChange} @@ -448,7 +448,7 @@ export function RfqFilterSheet({ <FormControl> <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> <div className="flex w-full justify-between"> - <SelectValue placeholder="상태 선택" /> + <SelectValue placeholder="견적상태 선택" /> {field.value && ( <Button type="button" @@ -535,7 +535,7 @@ export function RfqFilterSheet({ name="projectCode" render={({ field }) => ( <FormItem> - <FormLabel>프로젝트 코드</FormLabel> + <FormLabel>프로젝트</FormLabel> <FormControl> <div className="relative"> <Input @@ -571,11 +571,11 @@ export function RfqFilterSheet({ name="rfqType" render={({ field }) => ( <FormItem> - <FormLabel>견적 유형</FormLabel> + <FormLabel>견적 종류</FormLabel> <FormControl> <div className="relative"> <Input - placeholder="견적 유형 입력" + placeholder="견적 종류 입력" {...field} disabled={isPending} className={cn(field.value && "pr-8", "bg-white")} diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/table/rfq-items-dialog.tsx index eb6c05b1..466bcbd6 100644 --- a/lib/rfq-last/table/rfq-items-dialog.tsx +++ b/lib/rfq-last/table/rfq-items-dialog.tsx @@ -168,17 +168,6 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i] } - // 수량 포맷팅 - const formatQuantity = (quantity: number | null, uom: string | null) => { - if (!quantity) return "-" - return `${quantity.toLocaleString()}${uom ? ` ${uom}` : ""}` - } - - // 중량 포맷팅 - const formatWeight = (weight: number | null, uom: string | null) => { - if (!weight) return "-" - return `${weight.toLocaleString()} ${uom || "KG"}` - } return ( <Dialog open={isOpen} onOpenChange={onClose}> @@ -227,8 +216,10 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableHead className="w-[60px]">구분</TableHead> <TableHead className="w-[120px]">자재코드</TableHead> <TableHead>자재명</TableHead> - <TableHead className="w-[100px]">수량</TableHead> - <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[80px]">수량</TableHead> + <TableHead className="w-[60px]">수량단위</TableHead> + <TableHead className="w-[80px]">중량</TableHead> + <TableHead className="w-[60px]">중량단위</TableHead> <TableHead className="w-[100px]">납기일</TableHead> <TableHead className="w-[100px]">PR번호</TableHead> <TableHead className="w-[120px]">사양/설계문서</TableHead> @@ -247,6 +238,10 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableCell><Skeleton className="h-8 w-full" /></TableCell> <TableCell><Skeleton className="h-8 w-full" /></TableCell> <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> </TableRow> ))} </TableBody> @@ -263,8 +258,10 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableHead className="w-[60px]">구분</TableHead> <TableHead className="w-[120px]">자재코드</TableHead> <TableHead>자재명</TableHead> - <TableHead className="w-[100px]">수량</TableHead> - <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[80px]">수량</TableHead> + <TableHead className="w-[60px]">수량단위</TableHead> + <TableHead className="w-[80px]">중량</TableHead> + <TableHead className="w-[60px]">중량단위</TableHead> <TableHead className="w-[100px]">납기일</TableHead> <TableHead className="w-[100px]">PR번호</TableHead> <TableHead className="w-[100px]">PR 아이템 번호</TableHead> @@ -315,12 +312,22 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps </TableCell> <TableCell> <span className="text-sm font-medium"> - {formatQuantity(item.quantity, item.uom)} + {item.quantity ? item.quantity.toLocaleString() : "-"} </span> </TableCell> <TableCell> - <span className="text-sm"> - {formatWeight(item.grossWeight, item.gwUom)} + <span className="text-sm text-muted-foreground"> + {item.uom || "-"} + </span> + </TableCell> + <TableCell> + <span className="text-sm font-medium"> + {item.grossWeight ? item.grossWeight.toLocaleString() : "-"} + </span> + </TableCell> + <TableCell> + <span className="text-sm text-muted-foreground"> + {item.gwUom || "-"} </span> </TableCell> <TableCell> @@ -393,7 +400,7 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableCell> <div className="text-xs"> {[ - item.projectDef && `DEF: ${item.projectDef}`, + item.projectDef && `${item.projectDef}`, item.projectSc && `SC: ${item.projectSc}`, item.projectKl && `KL: ${item.projectKl}`, item.projectLc && `LC: ${item.projectLc}`, diff --git a/lib/rfq-last/table/rfq-table.tsx b/lib/rfq-last/table/rfq-table.tsx index 974662d9..09bf5af4 100644 --- a/lib/rfq-last/table/rfq-table.tsx +++ b/lib/rfq-last/table/rfq-table.tsx @@ -239,17 +239,17 @@ export function RfqTable({ }, [rfqCategory, setRowAction, router]); const filterFields: DataTableFilterField<RfqsLastView>[] = [ - { id: "rfqCode", label: "RFQ 코드" }, + { id: "rfqCode", label: "견적 No." }, { id: "projectName", label: "프로젝트명" }, { id: "itemName", label: "자재명" }, { id: "status", label: "상태" }, ]; const advancedFilterFields: DataTableAdvancedFilterField<RfqsLastView>[] = [ - { id: "rfqCode", label: "RFQ 코드", type: "text" }, + { id: "rfqCode", label: "견적 No.", type: "text" }, { id: "status", - label: "상태", + label: "견적상태", type: "select", options: [ { label: "RFQ 생성", value: "RFQ 생성" }, @@ -272,7 +272,16 @@ export function RfqTable({ { id: "dueDate", label: "마감일", type: "date" }, { id: "rfqSendDate", label: "발송일", type: "date" }, ...(rfqCategory === "general" ? [ - { id: "rfqType", label: "견적 유형", type: "text" }, + { + id: "rfqType", + label: "견적 유형", + type: "select", + options: [ + { label: "단가계약", value: "단가계약" }, + { label: "매각계약", value: "매각계약" }, + { label: "일반계약", value: "일반계약" }, + ] + }, { id: "rfqTitle", label: "견적 제목", type: "text" }, ] as DataTableAdvancedFilterField<RfqsLastView>[] : []), ...(rfqCategory === "itb" ? [ diff --git a/lib/rfq-last/vendor-response/editor/attachments-upload.tsx b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx index a2967767..ea7bb9c9 100644 --- a/lib/rfq-last/vendor-response/editor/attachments-upload.tsx +++ b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx @@ -1,11 +1,20 @@ "use client" import { useState, useRef } from "react" +import { useSession } from "next-auth/react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Alert, AlertDescription } from "@/components/ui/alert" import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Table, TableBody, TableCell, @@ -23,10 +32,13 @@ import { Paperclip, FileCheck, Calculator, - Wrench + Wrench, + X } from "lucide-react" import { formatBytes } from "@/lib/utils" import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { deleteVendorResponseAttachment } from "../../service" interface FileWithType extends File { attachmentType?: "구매" | "설계" @@ -37,6 +49,9 @@ interface AttachmentsUploadProps { attachments: FileWithType[] onAttachmentsChange: (files: FileWithType[]) => void existingAttachments?: any[] + onExistingAttachmentsChange?: (files: any[]) => void + responseId?: number + userId?: number } const acceptedFileTypes = { @@ -49,13 +64,18 @@ const acceptedFileTypes = { export default function AttachmentsUpload({ attachments, onAttachmentsChange, - existingAttachments = [] + existingAttachments = [], + onExistingAttachmentsChange, + responseId, + userId }: AttachmentsUploadProps) { const purchaseInputRef = useRef<HTMLInputElement>(null) const designInputRef = useRef<HTMLInputElement>(null) const [purchaseDragActive, setPurchaseDragActive] = useState(false) const [designDragActive, setDesignDragActive] = useState(false) const [uploadErrors, setUploadErrors] = useState<string[]>([]) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [fileToDelete, setFileToDelete] = useState<{file: any, isExisting: boolean, index: number} | null>(null) // 파일 유효성 검사 const validateFile = (file: File): string | null => { @@ -158,6 +178,57 @@ export default function AttachmentsUpload({ newFiles[index].attachmentType = newType onAttachmentsChange(newFiles) } + + // 파일 삭제 확인 + const handleDeleteClick = (file: any, isExisting: boolean, index: number) => { + setFileToDelete({ file, isExisting, index }) + setDeleteDialogOpen(true) + } + + // 파일 삭제 실행 + const handleDeleteConfirm = async () => { + if (!fileToDelete) return + + const { isExisting, index } = fileToDelete + + if (isExisting) { + // 기존 첨부파일 삭제 - 서버액션 호출 + if (responseId && userId && fileToDelete.file.id) { + try { + const result = await deleteVendorResponseAttachment({ + attachmentId: fileToDelete.file.id, + responseId, + userId + }) + if (result.success) { + // 클라이언트 상태 업데이트 + const newExistingAttachments = existingAttachments.filter((_, i) => i !== index) + onExistingAttachmentsChange?.(newExistingAttachments) + } else { + toast.error(`삭제 실패: ${result.error}`) + return + } + } catch (error) { + console.error('삭제 API 호출 실패:', error) + toast.error('삭제 중 오류가 발생했습니다.') + return + } + } + } else { + // 새 첨부파일 삭제 (클라이언트에서만) + const newFiles = attachments.filter((_, i) => i !== index) + onAttachmentsChange(newFiles) + } + + setDeleteDialogOpen(false) + setFileToDelete(null) + } + + // 파일 삭제 취소 + const handleDeleteCancel = () => { + setDeleteDialogOpen(false) + setFileToDelete(null) + } // 파일 아이콘 가져오기 const getFileIcon = (fileName: string) => { @@ -388,14 +459,24 @@ export default function AttachmentsUpload({ </Badge> </TableCell> <TableCell> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => window.open(file.filePath, '_blank')} - > - <Download className="h-4 w-4" /> - </Button> + <div className="flex items-center gap-1"> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => window.open(file.filePath, '_blank')} + > + <Download className="h-4 w-4" /> + </Button> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDeleteClick(file, true, index)} + > + <Trash2 className="h-4 w-4 text-red-500" /> + </Button> + </div> </TableCell> </TableRow> ))} @@ -449,7 +530,7 @@ export default function AttachmentsUpload({ type="button" variant="ghost" size="sm" - onClick={() => handleFileRemove(index)} + onClick={() => handleDeleteClick(file, false, index)} > <Trash2 className="h-4 w-4 text-red-500" /> </Button> @@ -461,6 +542,37 @@ export default function AttachmentsUpload({ </CardContent> </Card> )} + + {/* 파일 삭제 확인 다이얼로그 */} + <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>파일 삭제</DialogTitle> + <DialogDescription> + {fileToDelete?.isExisting ? '기존 첨부파일' : '새로 업로드한 파일'} "{fileToDelete?.file.originalFileName || fileToDelete?.file.name}"을(를) 삭제하시겠습니까? + <br /> + <strong>삭제된 파일은 복구할 수 없습니다.</strong> + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleDeleteCancel} + > + 취소 + </Button> + <Button + type="button" + variant="destructive" + onClick={handleDeleteConfirm} + > + <Trash2 className="h-4 w-4 mr-2" /> + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> </div> ) }
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx index 569546dd..fec9a2b9 100644 --- a/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx +++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx @@ -14,6 +14,11 @@ import RfqInfoHeader from "./rfq-info-header" import CommercialTermsForm from "./commercial-terms-form" import QuotationItemsTable from "./quotation-items-table" import AttachmentsUpload from "./attachments-upload" + +interface FileWithType extends File { + attachmentType?: "구매" | "설계" + description?: string +} import { formatDate, formatCurrency } from "@/lib/utils" import { Shield, FileText, CheckCircle, XCircle, Clock, Download, Eye, Save, Send, AlertCircle, Upload, } from "lucide-react" import { Progress } from "@/components/ui/progress" @@ -103,11 +108,34 @@ export default function VendorResponseEditor({ const router = useRouter() const [loading, setLoading] = useState(false) const [activeTab, setActiveTab] = useState("info") - const [attachments, setAttachments] = useState<File[]>([]) + const [attachments, setAttachments] = useState<FileWithType[]>([]) + const [existingAttachments, setExistingAttachments] = useState<any[]>([]) + const [deletedAttachments, setDeletedAttachments] = useState<any[]>([]) const [uploadProgress, setUploadProgress] = useState(0) // 추가 console.log(existingResponse,"existingResponse") + // existingResponse가 변경될 때 existingAttachments 초기화 + useEffect(() => { + if (existingResponse?.attachments) { + setExistingAttachments([...existingResponse.attachments]) + setDeletedAttachments([]) // 삭제 목록 초기화 + } else { + setExistingAttachments([]) + setDeletedAttachments([]) + } + }, [existingResponse?.attachments]) + + // 기존 첨부파일 삭제 처리 + const handleExistingAttachmentsChange = (files: any[]) => { + const currentAttachments = existingResponse?.attachments || [] + const deleted = currentAttachments.filter( + curr => !files.some(f => f.id === curr.id) + ) + setExistingAttachments(files) + setDeletedAttachments(prev => [...prev, ...deleted]) + } + // Form 초기값 설정 const defaultValues: VendorResponseFormData = { @@ -229,10 +257,20 @@ export default function VendorResponseEditor({ try { const formData = new FormData() - const fileMetadata = attachments.map((file: any) => ({ + const fileMetadata = attachments.map((file: FileWithType) => ({ attachmentType: file.attachmentType || "기타", description: file.description || "" })) + + // 삭제된 첨부파일 ID 목록 + const deletedAttachmentIds = deletedAttachments.map(file => file.id) + + // 디버그: 첨부파일 attachmentType 확인 + console.log('Attachments with types:', attachments.map(f => ({ + name: f.name, + attachmentType: f.attachmentType, + size: f.size + }))) // 기본 데이터 추가 @@ -246,7 +284,8 @@ export default function VendorResponseEditor({ submittedBy: isSubmit ? userId : null, totalAmount: data.quotationItems.reduce((sum, item) => sum + item.totalPrice, 0), updatedBy: userId, - fileMetadata + fileMetadata, + deletedAttachmentIds } console.log('Submitting data:', submitData) // 디버깅용 @@ -468,7 +507,10 @@ export default function VendorResponseEditor({ <AttachmentsUpload attachments={attachments} onAttachmentsChange={setAttachments} - existingAttachments={existingResponse?.attachments} + existingAttachments={existingAttachments} + onExistingAttachmentsChange={handleExistingAttachmentsChange} + responseId={existingResponse?.id} + userId={userId} /> </TabsContent> </Tabs> diff --git a/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx index 2b3138d6..3ca01191 100644 --- a/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx +++ b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx @@ -67,7 +67,7 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment const attachments = await getRfqAttachmentsAction(rfqId); if (!attachments.success || attachments.data.length === 0) { - toast.error(result.error || "다운로드할 파일이 없습니다"); + toast.error(attachments.error || "다운로드할 파일이 없습니다"); } diff --git a/lib/rfq-last/vendor-response/rfq-items-dialog.tsx b/lib/rfq-last/vendor-response/rfq-items-dialog.tsx index daa692e9..9790a1bd 100644 --- a/lib/rfq-last/vendor-response/rfq-items-dialog.tsx +++ b/lib/rfq-last/vendor-response/rfq-items-dialog.tsx @@ -94,7 +94,7 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps if (result.success) { setItems(result.data) - setStatistics(result.statistics) + setStatistics(result.statistics ?? null) } else { toast.error(result.error || "품목을 불러오는데 실패했습니다") setItems([]) @@ -118,17 +118,6 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps window.open(specUrl, '_blank', 'noopener,noreferrer') } - // 수량 포맷팅 - const formatQuantity = (quantity: number | null, uom: string | null) => { - if (!quantity) return "-" - return `${quantity.toLocaleString()}${uom ? ` ${uom}` : ""}` - } - - // 중량 포맷팅 - const formatWeight = (weight: number | null, uom: string | null) => { - if (!weight) return "-" - return `${weight.toLocaleString()} ${uom || "KG"}` - } return ( <Dialog open={isOpen} onOpenChange={onClose}> @@ -177,8 +166,10 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableHead className="w-[60px]">구분</TableHead> <TableHead className="w-[120px]">자재코드</TableHead> <TableHead>자재명</TableHead> - <TableHead className="w-[100px]">수량</TableHead> - <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[80px]">수량</TableHead> + <TableHead className="w-[60px]">수량단위</TableHead> + <TableHead className="w-[80px]">중량</TableHead> + <TableHead className="w-[60px]">중량단위</TableHead> <TableHead className="w-[100px]">납기일</TableHead> <TableHead className="w-[100px]">PR번호</TableHead> <TableHead className="w-[80px]">사양</TableHead> @@ -197,6 +188,9 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableCell><Skeleton className="h-8 w-full" /></TableCell> <TableCell><Skeleton className="h-8 w-full" /></TableCell> <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> </TableRow> ))} </TableBody> @@ -213,8 +207,10 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableHead className="w-[60px]">구분</TableHead> <TableHead className="w-[120px]">자재코드</TableHead> <TableHead>자재명</TableHead> - <TableHead className="w-[100px]">수량</TableHead> - <TableHead className="w-[100px]">중량</TableHead> + <TableHead className="w-[80px]">수량</TableHead> + <TableHead className="w-[60px]">수량단위</TableHead> + <TableHead className="w-[80px]">중량</TableHead> + <TableHead className="w-[60px]">중량단위</TableHead> <TableHead className="w-[100px]">납기일</TableHead> <TableHead className="w-[100px]">PR번호</TableHead> <TableHead className="w-[100px]">사양</TableHead> @@ -264,12 +260,22 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps </TableCell> <TableCell> <span className="text-sm font-medium"> - {formatQuantity(item.quantity, item.uom)} + {item.quantity ? item.quantity.toLocaleString() : "-"} </span> </TableCell> <TableCell> - <span className="text-sm"> - {formatWeight(item.grossWeight, item.gwUom)} + <span className="text-sm text-muted-foreground"> + {item.uom || "-"} + </span> + </TableCell> + <TableCell> + <span className="text-sm font-medium"> + {item.grossWeight ? item.grossWeight.toLocaleString() : "-"} + </span> + </TableCell> + <TableCell> + <span className="text-sm text-muted-foreground"> + {item.gwUom || "-"} </span> </TableCell> <TableCell> @@ -313,7 +319,7 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps <TableCell> <div className="text-xs"> {[ - item.projectDef && `DEF: ${item.projectDef}`, + item.projectDef && `${item.projectDef}`, item.projectSc && `SC: ${item.projectSc}`, item.projectKl && `KL: ${item.projectKl}`, item.projectLc && `LC: ${item.projectLc}`, diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx index ff3e27cc..7eae48db 100644 --- a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -436,7 +436,7 @@ export function BatchUpdateConditionsDialog({ className="w-full justify-between" disabled={!fieldsToUpdate.currency} > - <span className="text-muted-foreground"> + <span className="truncate"> {field.value || "통화 선택"} </span> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index ef906ed6..89a42602 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -451,6 +451,7 @@ export function RfqVendorTable({ buffer: number[]; fileName: string; }>; + hasToSendEmail?: boolean; }) => { try { // 서버 액션 호출 @@ -461,6 +462,7 @@ export function RfqVendorTable({ attachmentIds: data.attachments, message: data.message, generatedPdfs: data.generatedPdfs, + hasToSendEmail: data.hasToSendEmail, }); // 성공 후 처리 diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index ed43d87f..e63086ad 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -86,7 +86,14 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; import { Progress } from "@/components/ui/progress"; +import { getRfqEmailTemplate } from "../service"; interface ContractToGenerate { vendorId: number; @@ -164,6 +171,46 @@ interface RfqInfo { quotationType?: string; evaluationApply?: boolean; contractType?: string; + + // 추가 필드들 (HTML 템플릿에서 사용되는 변수들) + customerName?: string; + customerCode?: string; + shipType?: string; + shipClass?: string; + shipCount?: number; + projectFlag?: string; + flag?: string; + contractStartDate?: string; + contractEndDate?: string; + scDate?: string; + dlDate?: string; + itemCode?: string; + itemName?: string; + itemCount?: number; + prNumber?: string; + prIssueDate?: string; + warrantyDescription?: string; + repairDescription?: string; + totalWarrantyDescription?: string; + requiredDocuments?: string[]; + contractRequirements?: { + hasNda: boolean; + ndaDescription: string; + hasGeneralGtc: boolean; + generalGtcDescription: string; + hasProjectGtc: boolean; + projectGtcDescription: string; + hasAgreement: boolean; + agreementDescription: string; + }; + vendorCountry?: string; + formattedDueDate?: string; + systemName?: string; + hasAttachments?: boolean; + attachmentsCount?: number; + language?: string; + companyName?: string; + now?: Date; } interface VendorWithRecipients extends Vendor { @@ -202,7 +249,14 @@ interface SendRfqDialogProps { attachments: number[]; message?: string; generatedPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>; - }) => Promise<void>; + hasToSendEmail?: boolean; + }) => Promise<{ + success: boolean; + message: string; + sentCount?: number; + failedCount?: number; + error?: string; + }>; } // 이메일 유효성 검사 함수 @@ -252,6 +306,13 @@ export function SendRfqDialog({ // 재전송 시 기본계약 스킵 옵션 - 업체별 관리 const [skipContractsForVendor, setSkipContractsForVendor] = React.useState<Record<number, boolean>>({}); + // 이메일 템플릿 관련 상태 + const [activeTab, setActiveTab] = React.useState<"recipients" | "template">("recipients"); + const [selectedTemplateSlug, setSelectedTemplateSlug] = React.useState<string>(""); + const [templatePreview, setTemplatePreview] = React.useState<{ subject: string; content: string } | null>(null); + const [isGeneratingPreview, setIsGeneratingPreview] = React.useState(false); + const [hasToSendEmail, setHasToSendEmail] = React.useState(true); // 이메일 발송 여부 + const generateContractPdf = async ( vendor: VendorWithRecipients, contractType: string, @@ -354,6 +415,130 @@ export function SendRfqDialog({ } }; + // 템플릿 미리보기 생성 + const generateTemplatePreview = React.useCallback(async (templateSlug: string) => { + + try { + setIsGeneratingPreview(true); + const template = await getRfqEmailTemplate(); + templateSlug = template?.slug || ""; + + const response = await fetch('/api/email-template/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + templateSlug, + sampleData: { + // 기본 RFQ 정보 (실제 데이터 사용) + rfqCode: rfqInfo?.rfqCode || '', + rfqTitle: rfqInfo?.rfqTitle || '', + projectCode: rfqInfo?.projectCode, + projectName: rfqInfo?.projectName, + vendorName: "업체명 예시", // 실제로는 선택된 벤더 이름 사용 + picName: rfqInfo?.picName, + picCode: rfqInfo?.picCode, + picTeam: rfqInfo?.picTeam, + dueDate: rfqInfo?.dueDate, + + // 프로젝트 관련 정보 + customerName: rfqInfo?.customerName || (rfqInfo?.projectCode ? `${rfqInfo.projectCode} 고객사` : undefined), + customerCode: rfqInfo?.customerCode || rfqInfo?.projectCode, + shipType: rfqInfo?.shipType || "선종 정보", + shipClass: rfqInfo?.shipClass || "선급 정보", + shipCount: rfqInfo?.shipCount || 1, + projectFlag: rfqInfo?.projectFlag || "KR", + flag: rfqInfo?.flag || "한국", + contractStartDate: rfqInfo?.contractStartDate || new Date().toISOString().split('T')[0], + contractEndDate: rfqInfo?.contractEndDate || new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + scDate: rfqInfo?.scDate || new Date().toISOString().split('T')[0], + dlDate: rfqInfo?.dlDate || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + + // 패키지/자재 정보 + packageNo: rfqInfo?.packageNo, + packageName: rfqInfo?.packageName, + materialGroup: rfqInfo?.materialGroup, + materialGroupDesc: rfqInfo?.materialGroupDesc, + + // 품목 정보 + itemCode: rfqInfo?.itemCode || "품목코드", + itemName: rfqInfo?.itemName || "품목명", + itemCount: rfqInfo?.itemCount || 1, + prNumber: rfqInfo?.prNumber || "PR-001", + prIssueDate: rfqInfo?.prIssueDate || new Date().toISOString().split('T')[0], + + // 보증 정보 + warrantyDescription: rfqInfo?.warrantyDescription || "제조사의 표준 보증 조건 적용", + repairDescription: rfqInfo?.repairDescription || "하자 발생 시 무상 수리", + totalWarrantyDescription: rfqInfo?.totalWarrantyDescription || "전체 품목에 대한 보증 적용", + + // 필요 문서 + requiredDocuments: rfqInfo?.requiredDocuments || [ + "상세 견적서", + "납기 계획서", + "품질 보증서", + "기술 사양서" + ], + + // 계약 요구사항 + contractRequirements: rfqInfo?.contractRequirements || { + hasNda: true, + ndaDescription: "NDA (비밀유지계약)", + hasGeneralGtc: true, + generalGtcDescription: "General GTC", + hasProjectGtc: !!rfqInfo?.projectCode, + projectGtcDescription: `Project GTC (${rfqInfo?.projectCode || ''})`, + hasAgreement: false, + agreementDescription: "기술 자료 제공 동의서" + }, + + // 벤더 정보 + vendorCountry: rfqInfo?.vendorCountry || "한국", + + // 시스템 정보 + formattedDueDate: rfqInfo?.formattedDueDate || (rfqInfo?.dueDate ? new Date(rfqInfo.dueDate).toLocaleDateString('ko-KR') : ''), + systemName: rfqInfo?.systemName || "SHI EVCP", + hasAttachments: rfqInfo?.hasAttachments || false, + attachmentsCount: rfqInfo?.attachmentsCount || 0, + + // 언어 설정 + language: rfqInfo?.language || "ko", + + // 회사 정보 (t helper 대체용) + companyName: "삼성중공업", + email: "삼성중공업", + + // 현재 시간 + now: new Date(), + + // 기타 정보 + designPicName: rfqInfo?.designPicName, + designTeam: rfqInfo?.designTeam, + quotationType: rfqInfo?.quotationType, + evaluationApply: rfqInfo?.evaluationApply, + contractType: rfqInfo?.contractType + } + }) + }); + + const data = await response.json(); + + if (data.success) { + setTemplatePreview({ + subject: data.subject || '', + content: data.html || '' + }); + } else { + console.error('미리보기 생성 실패:', data.error); + setTemplatePreview(null); + } + } catch (error) { + console.error('미리보기 생성 실패:', error); + setTemplatePreview(null); + } finally { + setIsGeneratingPreview(false); + } + }, [rfqInfo]); + // 초기화 React.useEffect(() => { if (open && selectedVendors.length > 0) { @@ -595,7 +780,7 @@ export function SendRfqDialog({ setIsGeneratingPdfs(false); setIsSending(true); - await onSend({ + const sendResult = await onSend({ vendors: vendorsWithRecipients.map(v => ({ vendorId: v.vendorId, vendorName: v.vendorName, @@ -623,12 +808,15 @@ export function SendRfqDialog({ key, ...data })), + // 이메일 발송 처리 (사용자 선택에 따라) + hasToSendEmail: hasToSendEmail, }); - toast.success( - `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` + - (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '') - ); + if (!sendResult.success) { + throw new Error(sendResult.message); + } + + toast.success(sendResult.message); onOpenChange(false); } catch (error) { @@ -641,7 +829,7 @@ export function SendRfqDialog({ setCurrentGeneratingContract(""); setSkipContractsForVendor({}); // 초기화 } - }, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]); + }, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor, hasToSendEmail]); // 전송 처리 const handleSend = async () => { @@ -695,9 +883,15 @@ export function SendRfqDialog({ </DialogDescription> </DialogHeader> - {/* ScrollArea 대신 div 사용 */} - <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(90vh - 200px)' }}> - <div className="space-y-6 pr-4"> + {/* 탭 구조 */} + <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "recipients" | "template")} className="flex-1 flex flex-col"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="recipients">수신자 설정</TabsTrigger> + <TabsTrigger value="template">이메일 템플릿</TabsTrigger> + </TabsList> + + <div className="flex-1 overflow-y-auto px-1 mt-4" style={{ maxHeight: 'calc(90vh - 240px)' }}> + <TabsContent value="recipients" className="mt-0 space-y-6 pr-4"> {/* 재발송 경고 메시지 - 재발송 업체가 있을 때만 표시 */} {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && ( <Alert className="border-yellow-500 bg-yellow-50"> @@ -1290,44 +1484,152 @@ export function SendRfqDialog({ <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 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> - {/* PDF 생성 진행 상황 표시 */} - {isGeneratingPdfs && ( - <Alert className="border-blue-500 bg-blue-50"> - <div className="space-y-3"> + {/* PDF 생성 진행 상황 표시 */} + {isGeneratingPdfs && ( + <Alert className="border-blue-500 bg-blue-50"> + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <RefreshCw className="h-4 w-4 animate-spin text-blue-600" /> + <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle> + </div> + <AlertDescription> + <div className="space-y-2"> + <p className="text-sm text-blue-700">{currentGeneratingContract}</p> + <Progress value={pdfGenerationProgress} className="h-2" /> + <p className="text-xs text-blue-600"> + {Math.round(pdfGenerationProgress)}% 완료 + </p> + </div> + </AlertDescription> + </div> + </Alert> + )} + </TabsContent> + + <TabsContent value="template" className="mt-0 space-y-6 pr-4"> + {/* 이메일 발송 설정 및 미리보기 섹션 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2 text-sm font-medium"> + <Mail className="h-4 w-4" /> + 이메일 발송 설정 + </div> <div className="flex items-center gap-2"> - <RefreshCw className="h-4 w-4 animate-spin text-blue-600" /> - <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle> + <Checkbox + id="hasToSendEmail" + checked={hasToSendEmail} + onCheckedChange={setHasToSendEmail} + /> + <Label htmlFor="hasToSendEmail" className="text-sm"> + 이메일 발송 + </Label> </div> - <AlertDescription> - <div className="space-y-2"> - <p className="text-sm text-blue-700">{currentGeneratingContract}</p> - <Progress value={pdfGenerationProgress} className="h-2" /> - <p className="text-xs text-blue-600"> - {Math.round(pdfGenerationProgress)}% 완료 - </p> - </div> - </AlertDescription> </div> - </Alert> - )} + {/* 이메일 발송 여부에 따른 설명 */} + <Alert className={cn( + "border-2", + hasToSendEmail ? "border-blue-200 bg-blue-50" : "border-gray-200 bg-gray-50" + )}> + <Mail className={cn("h-4 w-4", hasToSendEmail ? "text-blue-600" : "text-gray-600")} /> + <AlertTitle className={cn(hasToSendEmail ? "text-blue-800" : "text-gray-800")}> + {hasToSendEmail ? "이메일 발송 모드" : "RFQ만 발송 모드"} + </AlertTitle> + <AlertDescription className={cn("text-sm", hasToSendEmail ? "text-blue-700" : "text-gray-700")}> + {hasToSendEmail + ? "선택된 이메일 템플릿으로 RFQ와 함께 이메일을 발송합니다." + : "EVCP 시스템에서 RFQ만 발송하고 이메일은 발송하지 않습니다." + } + </AlertDescription> + </Alert> + {/* 이메일 발송 시에만 미리보기 표시 */} + {hasToSendEmail && ( + <div className="space-y-4"> + <div className="space-y-4"> + {/* 미리보기 새로고침 버튼 */} + <div className="flex justify-end"> + <Button + variant="outline" + size="sm" + onClick={() => generateTemplatePreview(selectedTemplateSlug)} + disabled={isGeneratingPreview} + > + {isGeneratingPreview ? ( + <RefreshCw className="h-4 w-4 animate-spin mr-2" /> + ) : ( + '미리보기 새로고침' + )} + </Button> + </div> + + {/* 미리보기 */} + <div className="space-y-2"> + <Label className="text-sm font-medium">이메일 미리보기</Label> + {isGeneratingPreview ? ( + <div className="h-96 border rounded-lg flex items-center justify-center"> + <div className="text-center"> + <RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2 text-blue-500" /> + <p className="text-sm text-muted-foreground">미리보기 생성 중...</p> + </div> + </div> + ) : templatePreview ? ( + <div className="space-y-4"> + {/* 제목 미리보기 */} + <div className="p-3 bg-blue-50 rounded-lg"> + <Label className="text-xs font-medium text-blue-900">제목:</Label> + <p className="font-semibold text-blue-900 break-words">{templatePreview.subject}</p> + </div> + + {/* 본문 미리보기 */} + <div className="border rounded-lg bg-white"> + <iframe + srcDoc={templatePreview.content} + sandbox="allow-same-origin" + className="w-full h-96 border-0 rounded-lg" + title="Template Preview" + /> + </div> + </div> + ) : ( + <div className="h-96 border rounded-lg flex items-center justify-center"> + <div className="text-center"> + <Mail className="h-12 w-12 text-gray-400 mx-auto mb-4" /> + <p className="text-gray-500 mb-2">미리보기를 생성하면 이메일 내용이 표시됩니다</p> + <Button + variant="outline" + size="sm" + onClick={() => generateTemplatePreview(selectedTemplateSlug)} + > + 미리보기 생성 + </Button> + </div> + </div> + )} + </div> + </div> + + </div> + )} + + </div> + </TabsContent> </div> - </div> + </Tabs> <DialogFooter className="flex-shrink-0"> <Alert className="max-w-md"> diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx index 17eed54c..074924eb 100644 --- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx +++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx @@ -586,7 +586,6 @@ export function VendorResponseDetailDialog({ <TableHead className="text-right">단가</TableHead> <TableHead className="text-right">금액</TableHead> <TableHead>납기일</TableHead> - <TableHead>제조사</TableHead> </TableRow> </TableHeader> <TableBody> @@ -608,7 +607,6 @@ export function VendorResponseDetailDialog({ ? format(new Date(item.vendorDeliveryDate), "MM-dd") : "-"} </TableCell> - <TableCell>{item.manufacturer || "-"}</TableCell> </TableRow> ))} </TableBody> |
