diff options
Diffstat (limited to 'lib')
31 files changed, 11163 insertions, 585 deletions
diff --git a/lib/basic-contract/gen-service.ts b/lib/basic-contract/gen-service.ts new file mode 100644 index 00000000..5619f98e --- /dev/null +++ b/lib/basic-contract/gen-service.ts @@ -0,0 +1,399 @@ +import db from "@/db/db"; +import { + basicContract, + basicContractTemplates,vendors, + rfqLastDetails +} from "@/db/schema"; +import { eq, and, ilike } from "drizzle-orm"; +import { addDays } from "date-fns"; +import { writeFile, mkdir } from "fs/promises"; +import path from "path"; + +interface BasicContractParams { + templateName: string; + vendorId: number; + biddingCompanyId?: number | null; + rfqCompanyId: number| null; + generalContractId?: number | null; + requestedBy: number; +} + +/** + * 기본 계약서를 생성하는 공용 함수 + * @param params 계약서 생성에 필요한 파라미터 + * @returns 생성된 계약서 정보 + */ +export async function generateBasicContract(params: BasicContractParams) { + const { + templateName, + vendorId, + biddingCompanyId, + rfqCompanyId, + generalContractId, + requestedBy + } = params; + + try { + // 1. 템플릿 조회 (ACTIVE 상태인 최신 리비전) + const [template] = await db + .select() + .from(basicContractTemplates) + .where( + and( + ilike(basicContractTemplates.templateName, templateName), + eq(basicContractTemplates.status, "ACTIVE") + ) + ) + .limit(1); + + if (!template) { + throw new Error(`템플릿을 찾을 수 없습니다: ${templateName}`); + } + + // 2. 기본 계약서 생성 + const [newContract] = await db + .insert(basicContract) + .values({ + templateId: template.id, + vendorId: vendorId, + biddingCompanyId: biddingCompanyId, + rfqCompanyId: rfqCompanyId, + generalContractId: generalContractId, + requestedBy: requestedBy, + status: "PENDING", // 초기 상태 + fileName: template.fileName || `${templateName}_contract.pdf`, + filePath: template.filePath || "", + deadline: addDays(new Date(), 10), // 마감일은 요청일로부터 10일 후 + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + return { + success: true, + contractId: newContract.id, + templateName: templateName, + status: newContract.status, + deadline: newContract.deadline + }; + + } catch (error) { + console.error(`기본계약 생성 실패 (${templateName}):`, error); + throw new Error( + error instanceof Error + ? error.message + : "기본계약 생성 중 오류가 발생했습니다." + ); + } +} + +/** + * 벤더의 기본계약 요구사항에 따라 계약서들을 일괄 생성 + */ +export async function generateBasicContractsForVendor({ + vendorId, + rfqDetailId, + contractRequirements, + requestedBy, + projectCode +}: { + vendorId: number; + rfqDetailId: number; + contractRequirements: { + ndaYn: boolean; + generalGtcYn: boolean; + projectGtcYn: boolean; + agreementYn: boolean; + }; + requestedBy: number; + projectCode?: string; +}) { + const results = []; + const errors = []; + + try { + // NDA (비밀유지계약서) + if (contractRequirements.ndaYn) { + try { + const result = await generateBasicContract({ + templateName: "비밀", + vendorId, + rfqCompanyId: rfqDetailId, + requestedBy + }); + results.push({ type: "NDA", ...result }); + } catch (error) { + errors.push({ type: "NDA", error: error instanceof Error ? error.message : "알 수 없는 오류" }); + } + } + + // General GTC (일반 거래약관) + if (contractRequirements.generalGtcYn) { + try { + const result = await generateBasicContract({ + templateName: "General GTC", + vendorId, + rfqCompanyId: rfqDetailId, + requestedBy + }); + results.push({ type: "General GTC", ...result }); + } catch (error) { + errors.push({ type: "General GTC", error: error instanceof Error ? error.message : "알 수 없는 오류" }); + } + } + + // Project GTC (프로젝트별 거래약관) + if (contractRequirements.projectGtcYn && projectCode) { + try { + // 프로젝트별 템플릿명 생성 (예: "프로젝트거래약관_PJ001") + const templateName = `${projectCode}`; + + // 먼저 프로젝트별 템플릿이 있는지 확인 + const [projectTemplate] = await db + .select() + .from(basicContractTemplates) + .where( + and( + eq(basicContractTemplates.templateName, templateName), + eq(basicContractTemplates.status, "ACTIVE") + ) + ); + + // 프로젝트별 템플릿이 없으면 일반 프로젝트 거래약관 사용 + const actualTemplateName = projectTemplate + + const result = await generateBasicContract({ + templateName: actualTemplateName, + vendorId, + rfqCompanyId: rfqDetailId, + requestedBy + }); + results.push({ type: "Project GTC", projectCode, ...result }); + } catch (error) { + errors.push({ type: "Project GTC", error: error instanceof Error ? error.message : "알 수 없는 오류" }); + } + } + + // 기술자료 제공 동의서 + if (contractRequirements.agreementYn) { + try { + const result = await generateBasicContract({ + templateName: "기술", + vendorId, + rfqCompanyId: rfqDetailId, + requestedBy + }); + results.push({ type: "기술자료 제공 동의서", ...result }); + } catch (error) { + errors.push({ type: "기술자료 제공 동의서", error: error instanceof Error ? error.message : "알 수 없는 오류" }); + } + } + + return { + success: results.length > 0, + results, + errors, + totalCreated: results.length, + totalFailed: errors.length + }; + + } catch (error) { + console.error("기본계약 일괄 생성 실패:", error); + throw new Error( + error instanceof Error + ? error.message + : "기본계약 일괄 생성 중 오류가 발생했습니다." + ); + } +} + +/** + * 기본계약 상태 업데이트 + */ +export async function updateBasicContractStatus( + contractId: number, + status: string, + additionalData?: { + vendorSignedAt?: Date; + buyerSignedAt?: Date; + legalReviewRequestedAt?: Date; + legalReviewCompletedAt?: Date; + completedAt?: Date; + } +) { + try { + const [updated] = await db + .update(basicContract) + .set({ + status, + updatedAt: new Date(), + ...additionalData + }) + .where(eq(basicContract.id, contractId)) + .returning(); + + return { + success: true, + contract: updated + }; + } catch (error) { + console.error("기본계약 상태 업데이트 실패:", error); + throw new Error( + error instanceof Error + ? error.message + : "기본계약 상태 업데이트 중 오류가 발생했습니다." + ); + } +} + + +interface BasicContractParams { + templateName: string; + vendorId: number; + biddingCompanyId?: number | null; + rfqCompanyId: number; + generalContractId?: number | null; + requestedBy: number; +} + +/** + * 템플릿 데이터 준비 (PDF 변환 전 단계) + */ +export async function prepareContractTemplate(params: BasicContractParams) { + const { templateName, vendorId } = params; + + try { + // 1. 템플릿 조회 + const [template] = await db + .select() + .from(basicContractTemplates) + .where( + and( + ilike(basicContractTemplates.templateName, templateName), + eq(basicContractTemplates.status, "ACTIVE") + ) + ) + .limit(1); + + if (!template) { + throw new Error(`템플릿을 찾을 수 없습니다: ${templateName}`); + } + + // 2. 벤더 정보 조회 + const [vendor] = await db + .select() + .from(vendors) + .where(eq(vendors.id, vendorId)) + .limit(1); + + if (!vendor) { + throw new Error(`벤더를 찾을 수 없습니다: ${vendorId}`); + } + + // 3. 템플릿 데이터 준비 + const templateData = { + company_name: vendor.vendorName || '협력업체명', + company_address: vendor.address || '주소', + company_address_detail: vendor.addressDetail || '', + company_postal_code: vendor.postalCode || '', + company_country: vendor.country || '대한민국', + representative_name: vendor.representativeName || '대표자명', + representative_email: vendor.representativeEmail || '', + representative_phone: vendor.representativePhone || '', + tax_id: vendor.taxId || '사업자번호', + corporate_registration_number: vendor.corporateRegistrationNumber || '', + phone_number: vendor.phone || '전화번호', + email: vendor.email || '', + website: vendor.website || '', + signature_date: new Date().toLocaleDateString('ko-KR'), + contract_date: new Date().toISOString().split('T')[0], + effective_date: new Date().toISOString().split('T')[0], + expiry_date: addDays(new Date(), 365).toISOString().split('T')[0], + vendor_code: vendor.vendorCode || '', + business_size: vendor.businessSize || '', + credit_rating: vendor.creditRating || '', + template_type: templateName, + contract_number: `BC-${new Date().getFullYear()}-${String(vendorId).padStart(4, '0')}-${Date.now()}`, + }; + + return { + success: true, + template, + vendor, + templateData, + params + }; + } catch (error) { + console.error(`템플릿 준비 실패 (${templateName}):`, error); + throw new Error( + error instanceof Error + ? error.message + : "템플릿 준비 중 오류가 발생했습니다." + ); + } +} + +/** + * PDF 파일 저장 및 DB 레코드 생성 + */ +export async function saveContractPdf({ + pdfBuffer, + fileName, + params, + templateId +}: { + pdfBuffer: Uint8Array; + fileName: string; + params: BasicContractParams; + templateId: number; +}) { + try { + // 1. PDF 파일 저장 + const outputDir = path.join(process.cwd(), process.env.NAS_PATH, "contracts", "generated"); + await mkdir(outputDir, { recursive: true }); + + const timestamp = Date.now(); + const finalFileName = `${fileName.replace('.pdf', '')}_${timestamp}.pdf`; + const outputPath = path.join(outputDir, finalFileName); + + await writeFile(outputPath, Buffer.from(pdfBuffer)); + + const relativePath = `/contracts/generated/${finalFileName}`; + + // 2. DB에 계약서 레코드 생성 + const [newContract] = await db + .insert(basicContract) + .values({ + templateId: templateId, + vendorId: params.vendorId, + biddingCompanyId: params.biddingCompanyId, + rfqCompanyId: params.rfqCompanyId, + generalContractId: params.generalContractId, + requestedBy: params.requestedBy, + status: "PENDING", + fileName: finalFileName, + filePath: relativePath, + deadline: addDays(new Date(), 10), + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + return { + success: true, + contractId: newContract.id, + templateName: params.templateName, + status: newContract.status, + deadline: newContract.deadline, + pdfPath: relativePath, + pdfFileName: finalFileName + }; + } catch (error) { + console.error("PDF 저장 실패:", error); + throw new Error( + error instanceof Error + ? error.message + : "PDF 저장 중 오류가 발생했습니다." + ); + } +}
\ No newline at end of file diff --git a/lib/basic-contract/vendor-table/basic-contract-columns.tsx b/lib/basic-contract/vendor-table/basic-contract-columns.tsx index 1b11285c..c8af2e02 100644 --- a/lib/basic-contract/vendor-table/basic-contract-columns.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-columns.tsx @@ -241,6 +241,57 @@ export function getColumns({ setRowAction, locale = 'ko', t }: GetColumnsProps): groupMap[groupName].push(childCol) }) + const contractTypeColumn: ColumnDef<BasicContractView> = { + id: "contractType", + accessorFn: (row) => { + // 계약 유형 판별 로직 + if (row.generalContractId) return "contract"; + if (row.rfqCompanyId) return "quotation"; + if (row.biddingCompanyId) return "bidding"; + return "general"; + }, + header: ({ column }) => ( + <DataTableColumnHeaderSimple + column={column} + title={locale === 'ko' ? "계약 소스" : "Contract Source"} + /> + ), + cell: ({ getValue }) => { + const type = getValue() as string; + + // 타입별 표시 텍스트와 스타일 정의 + const typeConfig = { + general: { + label: locale === 'ko' ? '일반' : 'General', + variant: 'outline' as const + }, + quotation: { + label: locale === 'ko' ? '견적' : 'Quotation', + variant: 'secondary' as const + }, + contract: { + label: locale === 'ko' ? '계약' : 'Contract', + variant: 'default' as const + }, + bidding: { + label: locale === 'ko' ? '입찰' : 'Bidding', + variant: 'destructive' as const + } + }; + + const config = typeConfig[type as keyof typeof typeConfig] || typeConfig.general; + + return ( + <Badge variant={config.variant}> + {config.label} + </Badge> + ); + }, + enableSorting: true, + enableHiding: true, + minSize: 80, + }; + // ---------------------------------------------------------------- // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- @@ -269,6 +320,7 @@ export function getColumns({ setRowAction, locale = 'ko', t }: GetColumnsProps): return [ selectColumn, downloadColumn, // 다운로드 컬럼 추가 + contractTypeColumn, ...nestedColumns, ] }
\ No newline at end of file diff --git a/lib/mail/templates/rfq-due-date-change.hbs b/lib/mail/templates/rfq-due-date-change.hbs new file mode 100644 index 00000000..9ff268bc --- /dev/null +++ b/lib/mail/templates/rfq-due-date-change.hbs @@ -0,0 +1,235 @@ +<!DOCTYPE html> +<html lang="{{language}}"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{#if (eq language 'ko')}}RFQ 마감일 변경 안내{{else}}RFQ Due Date Change Notification{{/if}}</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + background-color: #f5f5f5; + } + .container { + background-color: white; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + .header { + border-bottom: 2px solid #0066cc; + padding-bottom: 20px; + margin-bottom: 30px; + } + .header h1 { + color: #0066cc; + margin: 0; + font-size: 24px; + } + .alert-box { + background-color: #fff3cd; + border: 1px solid #ffc107; + border-radius: 4px; + padding: 15px; + margin: 20px 0; + } + .alert-box strong { + color: #856404; + } + .info-table { + width: 100%; + border-collapse: collapse; + margin: 20px 0; + } + .info-table th { + text-align: left; + padding: 10px; + background-color: #f8f9fa; + border: 1px solid #dee2e6; + width: 30%; + font-weight: 600; + } + .info-table td { + padding: 10px; + border: 1px solid #dee2e6; + } + .date-change { + background-color: #e3f2fd; + border-radius: 4px; + padding: 15px; + margin: 20px 0; + text-align: center; + } + .old-date { + color: #6c757d; + text-decoration: line-through; + font-size: 16px; + margin-bottom: 10px; + } + .arrow { + font-size: 24px; + color: #0066cc; + margin: 10px 0; + } + .new-date { + color: #0066cc; + font-size: 20px; + font-weight: bold; + } + .button { + display: inline-block; + padding: 12px 24px; + background-color: #0066cc; + color: white; + text-decoration: none; + border-radius: 4px; + font-weight: 600; + margin: 20px 0; + } + .button:hover { + background-color: #0052a3; + } + .footer { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #dee2e6; + font-size: 14px; + color: #6c757d; + } + .contact-info { + background-color: #f8f9fa; + padding: 15px; + border-radius: 4px; + margin-top: 20px; + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h1>{{#if (eq language 'ko')}}RFQ 마감일 변경 안내{{else}}RFQ Due Date Change Notification{{/if}}</h1> + </div> + + <p> + {{#if (eq language 'ko')}} + {{vendorName}} 귀하,<br><br> + 아래 RFQ의 마감일이 변경되었음을 알려드립니다. + {{else}} + Dear {{vendorName}},<br><br> + We would like to inform you that the due date for the following RFQ has been changed. + {{/if}} + </p> + + <div class="alert-box"> + <strong>⚠️ {{#if (eq language 'ko')}}중요: 마감일이 변경되었습니다{{else}}Important: Due date has been changed{{/if}}</strong> + </div> + + <table class="info-table"> + <tr> + <th>{{#if (eq language 'ko')}}RFQ 번호{{else}}RFQ Number{{/if}}</th> + <td><strong>{{rfqCode}}</strong></td> + </tr> + {{#if rfqTitle}} + <tr> + <th>{{#if (eq language 'ko')}}RFQ 제목{{else}}RFQ Title{{/if}}</th> + <td>{{rfqTitle}}</td> + </tr> + {{/if}} + {{#if rfqType}} + <tr> + <th>{{#if (eq language 'ko')}}RFQ 유형{{else}}RFQ Type{{/if}}</th> + <td>{{rfqType}}</td> + </tr> + {{/if}} + {{#if projectCode}} + <tr> + <th>{{#if (eq language 'ko')}}프로젝트 코드{{else}}Project Code{{/if}}</th> + <td>{{projectCode}}</td> + </tr> + {{/if}} + {{#if projectName}} + <tr> + <th>{{#if (eq language 'ko')}}프로젝트명{{else}}Project Name{{/if}}</th> + <td>{{projectName}}</td> + </tr> + {{/if}} + {{#if packageNo}} + <tr> + <th>{{#if (eq language 'ko')}}패키지 번호{{else}}Package No{{/if}}</th> + <td>{{packageNo}}</td> + </tr> + {{/if}} + {{#if packageName}} + <tr> + <th>{{#if (eq language 'ko')}}패키지명{{else}}Package Name{{/if}}</th> + <td>{{packageName}}</td> + </tr> + {{/if}} + {{#if itemCode}} + <tr> + <th>{{#if (eq language 'ko')}}품목 코드{{else}}Item Code{{/if}}</th> + <td>{{itemCode}}</td> + </tr> + {{/if}} + {{#if itemName}} + <tr> + <th>{{#if (eq language 'ko')}}품목명{{else}}Item Name{{/if}}</th> + <td>{{itemName}}</td> + </tr> + {{/if}} + </table> + + <div class="date-change"> + <h3>{{#if (eq language 'ko')}}마감일 변경{{else}}Due Date Change{{/if}}</h3> + <div class="old-date"> + {{#if (eq language 'ko')}}기존 마감일: {{else}}Previous Due Date: {{/if}}{{oldDueDate}} + </div> + <div class="arrow">▼</div> + <div class="new-date"> + {{#if (eq language 'ko')}}새 마감일: {{else}}New Due Date: {{/if}}{{newDueDate}} + </div> + </div> + + <p> + {{#if (eq language 'ko')}} + 변경된 마감일까지 견적서를 제출해 주시기 바랍니다. + 견적서 제출은 아래 포털 사이트를 통해 진행하실 수 있습니다. + {{else}} + Please submit your quotation by the new due date. + You can submit your quotation through the portal site below. + {{/if}} + </p> + + <div style="text-align: center;"> + <a href="{{portalUrl}}" class="button"> + {{#if (eq language 'ko')}}포털 사이트 바로가기{{else}}Go to Portal Site{{/if}} + </a> + </div> + + <div class="contact-info"> + <h4>{{#if (eq language 'ko')}}담당자 정보{{else}}Contact Information{{/if}}</h4> + <p> + {{#if (eq language 'ko')}}구매 담당자{{else}}Procurement PIC{{/if}}: {{picName}}<br> + {{#if (eq language 'ko')}}이메일{{else}}Email{{/if}}: <a href="mailto:{{picEmail}}">{{picEmail}}</a> + {{#if engPicName}}<br>{{#if (eq language 'ko')}}기술 담당자{{else}}Engineering PIC{{/if}}: {{engPicName}}{{/if}} + </p> + </div> + + <div class="footer"> + <p> + {{#if (eq language 'ko')}} + 본 메일은 발신 전용입니다. 문의사항이 있으시면 위 담당자에게 연락 주시기 바랍니다.<br> + 감사합니다. + {{else}} + This is an automated email. If you have any questions, please contact the person in charge above.<br> + Thank you. + {{/if}} + </p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/rfq-invitation.hbs b/lib/mail/templates/rfq-invitation.hbs new file mode 100644 index 00000000..d3064d3c --- /dev/null +++ b/lib/mail/templates/rfq-invitation.hbs @@ -0,0 +1,301 @@ +<!DOCTYPE html> +<html lang="{{language}}"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{#if (eq language 'ko')}}RFQ 초대{{else}}RFQ Invitation{{/if}}</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 0 auto; + padding: 20px; + background-color: #f5f5f5; + } + .container { + background-color: white; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + .header { + border-bottom: 3px solid #1e40af; + padding-bottom: 20px; + margin-bottom: 30px; + } + .logo { + font-size: 24px; + font-weight: bold; + color: #1e40af; + } + .rfq-code { + background-color: #eff6ff; + color: #1e40af; + padding: 10px 15px; + border-radius: 6px; + display: inline-block; + font-weight: bold; + margin: 15px 0; + } + .info-section { + background-color: #f8fafc; + border-left: 4px solid #3b82f6; + padding: 20px; + margin: 20px 0; + border-radius: 4px; + } + .info-row { + display: flex; + padding: 8px 0; + border-bottom: 1px solid #e5e7eb; + } + .info-row:last-child { + border-bottom: none; + } + .info-label { + font-weight: 600; + color: #4b5563; + min-width: 150px; + padding-right: 15px; + } + .info-value { + color: #111827; + flex: 1; + } + .due-date { + background-color: #fef2f2; + color: #dc2626; + padding: 15px; + border-radius: 6px; + text-align: center; + margin: 20px 0; + font-size: 18px; + font-weight: bold; + } + .action-button { + background-color: #3b82f6; + color: white; + padding: 15px 30px; + text-decoration: none; + border-radius: 6px; + display: inline-block; + font-weight: bold; + margin: 20px 0; + } + .action-button:hover { + background-color: #2563eb; + } + .custom-message { + background-color: #fef3c7; + border: 1px solid #f59e0b; + padding: 15px; + border-radius: 6px; + margin: 20px 0; + } + .attachments { + background-color: #f0f9ff; + border: 1px solid #3b82f6; + padding: 15px; + border-radius: 6px; + margin: 20px 0; + } + .footer { + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid #e5e7eb; + color: #6b7280; + font-size: 14px; + } + .contact-info { + background-color: #f9fafb; + padding: 15px; + border-radius: 6px; + margin-top: 20px; + } + table { + width: 100%; + border-collapse: collapse; + } + th { + background-color: #f3f4f6; + padding: 10px; + text-align: left; + font-weight: 600; + } + td { + padding: 10px; + border-bottom: 1px solid #e5e7eb; + } + </style> +</head> +<body> + <div class="container"> + <!-- Header --> + <div class="header"> + <div class="logo">{{t 'email.company_name'}}</div> + <h1>{{#if (eq language 'ko')}}견적 요청서 (RFQ){{else}}Request for Quotation{{/if}}</h1> + </div> + + <!-- Greeting --> + <p>{{#if (eq language 'ko')}}{{vendorName}} 귀하{{else}}Dear {{vendorName}}{{/if}},</p> + + <p> + {{#if (eq language 'ko')}} + 귀사를 당사의 입찰에 초대하게 되어 영광입니다. 아래 RFQ에 대한 견적서를 제출해 주시기 바랍니다. + {{else}} + We are pleased to invite your company to submit a quotation for the following RFQ. + {{/if}} + </p> + + <!-- RFQ Code --> + <div class="rfq-code"> + {{#if (eq rfqType '일반견적')}}견적{{else if (eq rfqType 'ITB')}}ITB{{else}}RFQ{{/if}} No: {{rfqCode}} + </div> + + <!-- RFQ Information --> + <div class="info-section"> + <h3>{{#if (eq language 'ko')}}견적 요청 정보{{else}}RFQ Information{{/if}}</h3> + <table> + {{#if rfqTitle}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}견적명{{else}}Title{{/if}}</td> + <td class="info-value">{{rfqTitle}}</td> + </tr> + {{/if}} + {{#if projectCode}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}프로젝트{{else}}Project{{/if}}</td> + <td class="info-value">{{projectCode}} - {{projectName}}</td> + </tr> + {{/if}} + {{#if packageNo}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}패키지{{else}}Package{{/if}}</td> + <td class="info-value">{{packageNo}} - {{packageName}}</td> + </tr> + {{/if}} + {{#if itemCode}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}품목{{else}}Item{{/if}}</td> + <td class="info-value">{{itemCode}} - {{itemName}}</td> + </tr> + {{/if}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}품목 수{{else}}Number of Items{{/if}}</td> + <td class="info-value">{{prItemsCount}} {{#if (eq language 'ko')}}개{{else}}items{{/if}}</td> + </tr> + {{#if attachmentsCount}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}첨부파일{{else}}Attachments{{/if}}</td> + <td class="info-value">{{attachmentsCount}} {{#if (eq language 'ko')}}개{{else}}file(s){{/if}}</td> + </tr> + {{/if}} + </table> + </div> + + <!-- Due Date --> + <div class="due-date"> + {{#if (eq language 'ko')}} + ⏰ 견적 제출 마감일: {{formatDate dueDate 'YYYY년 MM월 DD일'}} + {{else}} + ⏰ Quotation Due Date: {{formatDate dueDate 'MMMM DD, YYYY'}} + {{/if}} + </div> + + <!-- Custom Message --> + {{#if customMessage}} + <div class="custom-message"> + <h4>{{#if (eq language 'ko')}}추가 안내사항{{else}}Additional Information{{/if}}</h4> + <p>{{customMessage}}</p> + </div> + {{/if}} + + <!-- Portal Access --> + <div style="text-align: center; margin: 30px 0;"> + <p style="margin-bottom: 15px;"> + {{#if (eq language 'ko')}} + 아래 버튼을 클릭하여 상세 정보를 확인하고 견적서를 제출해 주세요. + {{else}} + Please click the button below to view details and submit your quotation. + {{/if}} + </p> + <a href="{{portalUrl}}" class="action-button"> + {{#if (eq language 'ko')}}견적서 작성하기{{else}}Submit Quotation{{/if}} + </a> + </div> + + <!-- Important Notes --> + <div class="info-section"> + <h4>{{#if (eq language 'ko')}}중요 안내사항{{else}}Important Notes{{/if}}</h4> + <ul> + <li> + {{#if (eq language 'ko')}} + 견적서는 반드시 마감일 이전에 제출해 주시기 바랍니다. + {{else}} + Please ensure your quotation is submitted before the due date. + {{/if}} + </li> + <li> + {{#if (eq language 'ko')}} + 모든 기술 사양 및 상업 조건을 충족하는 견적서를 제출해 주세요. + {{else}} + Please submit a quotation that meets all technical specifications and commercial terms. + {{/if}} + </li> + <li> + {{#if (eq language 'ko')}} + 문의사항이 있으시면 아래 담당자에게 연락 주시기 바랍니다. + {{else}} + If you have any questions, please contact the person below. + {{/if}} + </li> + </ul> + </div> + + <!-- Contact Information --> + <div class="contact-info"> + <h4>{{#if (eq language 'ko')}}담당자 정보{{else}}Contact Information{{/if}}</h4> + <table> + <tr> + <td class="info-label">{{#if (eq language 'ko')}}구매 담당자{{else}}Procurement Contact{{/if}}</td> + <td class="info-value">{{picName}}</td> + </tr> + <tr> + <td class="info-label">{{#if (eq language 'ko')}}이메일{{else}}Email{{/if}}</td> + <td class="info-value"><a href="mailto:{{picEmail}}">{{picEmail}}</a></td> + </tr> + {{#if engPicName}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}기술 담당자{{else}}Technical Contact{{/if}}</td> + <td class="info-value">{{engPicName}}</td> + </tr> + {{/if}} + </table> + </div> + + <!-- Footer --> + <div class="footer"> + <p> + {{#if (eq language 'ko')}} + 본 이메일은 RFQ 시스템에서 자동으로 발송되었습니다. + 회신은 위 담당자 이메일로 부탁드립니다. + {{else}} + This email was sent automatically from the RFQ system. + Please reply to the contact email above for any inquiries. + {{/if}} + </p> + <p style="margin-top: 10px;"> + <small> + {{#if (eq language 'ko')}} + © 2025 {{t 'email.company_name'}}. All rights reserved. | RFQ ID: #{{responseId}} + {{else}} + © 2025 {{t 'email.company_name'}}. All rights reserved. | RFQ ID: #{{responseId}} + {{/if}} + </small> + </p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/rfq-resend.hbs b/lib/mail/templates/rfq-resend.hbs new file mode 100644 index 00000000..1c21f6b9 --- /dev/null +++ b/lib/mail/templates/rfq-resend.hbs @@ -0,0 +1,364 @@ +<!DOCTYPE html> +<html lang="{{language}}"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{#if (eq language 'ko')}}RFQ 재발송{{else}}RFQ Resend{{/if}}</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 0 auto; + padding: 20px; + background-color: #f5f5f5; + } + .container { + background-color: white; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + .header { + border-bottom: 3px solid #1e40af; + padding-bottom: 20px; + margin-bottom: 30px; + } + .logo { + font-size: 24px; + font-weight: bold; + color: #1e40af; + } + .rfq-code { + background-color: #eff6ff; + color: #1e40af; + padding: 10px 15px; + border-radius: 6px; + display: inline-block; + font-weight: bold; + margin: 15px 0; + } + .resend-badge { + background-color: #fef3c7; + color: #d97706; + padding: 5px 10px; + border-radius: 4px; + display: inline-block; + font-weight: bold; + margin-left: 10px; + font-size: 14px; + } + .warning-box { + background-color: #fef2f2; + border: 2px solid #f87171; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + } + .warning-title { + color: #dc2626; + font-weight: bold; + font-size: 18px; + margin-bottom: 10px; + } + .warning-list { + color: #991b1b; + margin: 10px 0 10px 20px; + } + .info-section { + background-color: #f8fafc; + border-left: 4px solid #3b82f6; + padding: 20px; + margin: 20px 0; + border-radius: 4px; + } + .due-date { + background-color: #fef2f2; + color: #dc2626; + padding: 15px; + border-radius: 6px; + text-align: center; + margin: 20px 0; + font-size: 18px; + font-weight: bold; + } + .action-button { + background-color: #3b82f6; + color: white; + padding: 15px 30px; + text-decoration: none; + border-radius: 6px; + display: inline-block; + font-weight: bold; + margin: 20px 0; + } + .action-button:hover { + background-color: #2563eb; + } + .contract-requirements { + background-color: #fef3c7; + border: 1px solid #f59e0b; + padding: 15px; + border-radius: 6px; + margin: 20px 0; + } + .contract-list { + list-style: none; + padding: 0; + margin: 10px 0; + } + .contract-item { + padding: 8px 0; + border-bottom: 1px solid #fcd34d; + display: flex; + align-items: center; + } + .contract-item:last-child { + border-bottom: none; + } + .contract-icon { + width: 20px; + height: 20px; + margin-right: 10px; + background-color: #f59e0b; + color: white; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 12px; + } + .footer { + margin-top: 40px; + padding-top: 20px; + border-top: 1px solid #e5e7eb; + color: #6b7280; + font-size: 14px; + } + table { + width: 100%; + border-collapse: collapse; + } + td { + padding: 10px; + border-bottom: 1px solid #e5e7eb; + } + .info-label { + font-weight: 600; + color: #4b5563; + min-width: 150px; + } + .info-value { + color: #111827; + } + </style> +</head> +<body> + <div class="container"> + <!-- Header --> + <div class="header"> + <div class="logo">{{t 'email.company_name'}}</div> + <h1> + {{#if (eq language 'ko')}}견적 요청서 재발송{{else}}Request for Quotation - Resend{{/if}} + <span class="resend-badge">{{sendVersion}}{{#if (eq language 'ko')}}차{{else}}th Version{{/if}}</span> + </h1> + </div> + + <!-- Greeting --> + <p>{{#if (eq language 'ko')}}{{vendorName}} 귀하{{else}}Dear {{vendorName}}{{/if}},</p> + + <!-- Warning Box --> + <div class="warning-box"> + <div class="warning-title"> + ⚠️ {{#if (eq language 'ko')}}중요 공지{{else}}Important Notice{{/if}} + </div> + <p> + {{#if (eq language 'ko')}} + 본 RFQ는 수정된 내용으로 재발송되는 것입니다. 다음 사항에 유의해 주시기 바랍니다: + {{else}} + This RFQ is being resent with updated information. Please note the following: + {{/if}} + </p> + <ul class="warning-list"> + <li> + {{#if (eq language 'ko')}} + <strong>기존에 작성하신 견적 데이터는 모두 초기화되었습니다.</strong> + {{else}} + <strong>All previously submitted quotation data has been reset.</strong> + {{/if}} + </li> + <li> + {{#if (eq language 'ko')}} + 새로운 요구사항과 조건을 확인하시고 견적서를 다시 작성해 주세요. + {{else}} + Please review the new requirements and conditions and resubmit your quotation. + {{/if}} + </li> + <li> + {{#if (eq language 'ko')}} + 이전에 제출하신 견적서는 더 이상 유효하지 않습니다. + {{else}} + Previously submitted quotations are no longer valid. + {{/if}} + </li> + </ul> + </div> + + <!-- RFQ Code --> + <div class="rfq-code"> + {{#if (eq rfqType '일반견적')}}견적{{else if (eq rfqType 'ITB')}}ITB{{else}}RFQ{{/if}} No: {{rfqCode}} + </div> + + <!-- Basic Contract Requirements --> + {{#if hasContractRequirements}} + <div class="contract-requirements"> + <h4>{{#if (eq language 'ko')}}📋 기본계약 요청사항{{else}}📋 Basic Contract Requirements{{/if}}</h4> + <p> + {{#if (eq language 'ko')}} + 다음 계약서류의 제출이 요청됩니다: + {{else}} + The following contract documents are required: + {{/if}} + </p> + <ul class="contract-list"> + {{#if contracts.nda}} + <li class="contract-item"> + <span class="contract-icon">N</span> + {{#if (eq language 'ko')}}비밀유지계약서 (NDA){{else}}Non-Disclosure Agreement (NDA){{/if}} + </li> + {{/if}} + {{#if contracts.generalGtc}} + <li class="contract-item"> + <span class="contract-icon">G</span> + {{#if (eq language 'ko')}}일반 거래약관 (General GTC){{else}}General Terms and Conditions{{/if}} + </li> + {{/if}} + {{#if contracts.projectGtc}} + <li class="contract-item"> + <span class="contract-icon">P</span> + {{#if (eq language 'ko')}} + 프로젝트 거래약관 (Project {{projectCode}}) + {{else}} + Project Terms and Conditions ({{projectCode}}) + {{/if}} + </li> + {{/if}} + {{#if contracts.agreement}} + <li class="contract-item"> + <span class="contract-icon">T</span> + {{#if (eq language 'ko')}}기술자료 제공 동의서{{else}}Technical Data Agreement{{/if}} + </li> + {{/if}} + </ul> + </div> + {{/if}} + + <!-- RFQ Information --> + <div class="info-section"> + <h3>{{#if (eq language 'ko')}}견적 요청 정보{{else}}RFQ Information{{/if}}</h3> + <table> + {{#if rfqTitle}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}견적명{{else}}Title{{/if}}</td> + <td class="info-value">{{rfqTitle}}</td> + </tr> + {{/if}} + {{#if projectCode}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}프로젝트{{else}}Project{{/if}}</td> + <td class="info-value">{{projectCode}} - {{projectName}}</td> + </tr> + {{/if}} + {{#if packageNo}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}패키지{{else}}Package{{/if}}</td> + <td class="info-value">{{packageNo}} - {{packageName}}</td> + </tr> + {{/if}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}품목 수{{else}}Number of Items{{/if}}</td> + <td class="info-value">{{prItemsCount}} {{#if (eq language 'ko')}}개{{else}}items{{/if}}</td> + </tr> + {{#if attachmentsCount}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}첨부파일{{else}}Attachments{{/if}}</td> + <td class="info-value">{{attachmentsCount}} {{#if (eq language 'ko')}}개{{else}}file(s){{/if}}</td> + </tr> + {{/if}} + </table> + </div> + + <!-- Due Date --> + <div class="due-date"> + {{#if (eq language 'ko')}} + ⏰ 견적 제출 마감일: {{formatDate dueDate 'YYYY년 MM월 DD일'}} + {{else}} + ⏰ Quotation Due Date: {{formatDate dueDate 'MMMM DD, YYYY'}} + {{/if}} + </div> + + <!-- Custom Message --> + {{#if customMessage}} + <div class="info-section"> + <h4>{{#if (eq language 'ko')}}추가 안내사항{{else}}Additional Information{{/if}}</h4> + <p>{{customMessage}}</p> + </div> + {{/if}} + + <!-- Portal Access --> + <div style="text-align: center; margin: 30px 0;"> + <p style="margin-bottom: 15px; font-weight: bold; color: #dc2626;"> + {{#if (eq language 'ko')}} + ⚠️ 반드시 아래 버튼을 클릭하여 새로운 견적서를 작성해 주세요. + {{else}} + ⚠️ Please click the button below to submit a new quotation. + {{/if}} + </p> + <a href="{{portalUrl}}" class="action-button"> + {{#if (eq language 'ko')}}견적서 다시 작성하기{{else}}Resubmit Quotation{{/if}} + </a> + </div> + + <!-- Contact Information --> + <div class="info-section"> + <h4>{{#if (eq language 'ko')}}담당자 정보{{else}}Contact Information{{/if}}</h4> + <table> + <tr> + <td class="info-label">{{#if (eq language 'ko')}}구매 담당자{{else}}Procurement Contact{{/if}}</td> + <td class="info-value">{{picName}}</td> + </tr> + <tr> + <td class="info-label">{{#if (eq language 'ko')}}이메일{{else}}Email{{/if}}</td> + <td class="info-value"><a href="mailto:{{picEmail}}">{{picEmail}}</a></td> + </tr> + {{#if engPicName}} + <tr> + <td class="info-label">{{#if (eq language 'ko')}}기술 담당자{{else}}Technical Contact{{/if}}</td> + <td class="info-value">{{engPicName}}</td> + </tr> + {{/if}} + </table> + </div> + + <!-- Footer --> + <div class="footer"> + <p> + {{#if (eq language 'ko')}} + 본 이메일은 RFQ 시스템에서 자동으로 발송되었습니다. + 회신은 위 담당자 이메일로 부탁드립니다. + {{else}} + This email was sent automatically from the RFQ system. + Please reply to the contact email above for any inquiries. + {{/if}} + </p> + <p style="margin-top: 10px;"> + <small> + © 2025 {{t 'email.company_name'}}. All rights reserved. | RFQ ID: #{{responseId}} | Version: {{sendVersion}} + </small> + </p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/rfq-last/due-date-edit-button.tsx b/lib/rfq-last/due-date-edit-button.tsx new file mode 100644 index 00000000..85a18a63 --- /dev/null +++ b/lib/rfq-last/due-date-edit-button.tsx @@ -0,0 +1,216 @@ +"use client" + +import { useState } from "react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" +import { Calendar as CalendarIcon, Clock, Edit2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { useToast } from "@/hooks/use-toast" +import { useRouter } from "next/navigation" +import { updateRfqDueDate } from "./service" + +interface DueDateEditButtonProps { + rfqId: number + currentDueDate: Date | string | null + rfqCode: string + rfqTitle: string +} + +export function DueDateEditButton({ + rfqId, + currentDueDate, + rfqCode, + rfqTitle + }: DueDateEditButtonProps) { + const [open, setOpen] = useState(false) + const [date, setDate] = useState<Date | undefined>( + currentDueDate ? new Date(currentDueDate) : undefined + ) + const [time, setTime] = useState<string>( + currentDueDate + ? format(new Date(currentDueDate), "HH:mm") + : "17:00" // 기본값: 오후 5시 + ) + const [isLoading, setIsLoading] = useState(false) + const { toast } = useToast() + const router = useRouter() + + const handleSave = async () => { + if (!date) { + toast({ + title: "오류", + description: "마감일을 선택해주세요.", + variant: "destructive", + }) + return + } + + setIsLoading(true) + try { + // 날짜와 시간 결합 + const [hours, minutes] = time.split(':').map(Number) + const dateTime = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + hours, + minutes, + 0, + 0 + ) + + // ISO 문자열로 전송 (자동으로 로컬 타임존 포함) + const result = await updateRfqDueDate( + rfqId, + dateTime.toISOString(), + rfqCode, + rfqTitle + ) + + if (result.success) { + toast({ + title: "성공", + description: result.message, + }) + setOpen(false) + router.refresh() + } else { + toast({ + title: "오류", + description: result.message, + variant: "destructive", + }) + } + } catch (error) { + toast({ + title: "오류", + description: "마감일 수정 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button + variant="outline" + size="sm" + className="h-7 px-2" + > + <Edit2 className="h-3 w-3 mr-1" /> + 수정 + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-[425px]"> + <DialogHeader> + <DialogTitle>마감일 수정</DialogTitle> + <DialogDescription> + {rfqCode} {rfqTitle ? `- ${rfqTitle}` : ''}의 마감일을 수정합니다. + 변경 시 관련 업체에 이메일이 발송됩니다. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + {/* 날짜 선택 */} + <div className="grid gap-2"> + <label htmlFor="dueDate" className="text-sm font-medium"> + 마감 날짜 + </label> + <Popover> + <PopoverTrigger asChild> + <Button + id="dueDate" + variant="outline" + className={cn( + "w-full justify-start text-left font-normal", + !date && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {date ? format(date, "yyyy년 MM월 dd일", { locale: ko }) : "날짜를 선택하세요"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={date} + onSelect={setDate} + initialFocus + locale={ko} + disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} + /> + </PopoverContent> + </Popover> + </div> + + {/* 시간 선택 */} + <div className="grid gap-2"> + <label htmlFor="time" className="text-sm font-medium"> + 마감 시간 + </label> + <div className="flex items-center gap-2"> + <Clock className="h-4 w-4 text-muted-foreground" /> + <input + id="time" + type="time" + value={time} + onChange={(e) => setTime(e.target.value)} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + /> + </div> + </div> + + {/* 현재 마감일시 표시 */} + {currentDueDate && ( + <div className="text-sm text-muted-foreground"> + 현재 마감일시: {format(new Date(currentDueDate), "yyyy년 MM월 dd일 HH:mm", { locale: ko })} + </div> + )} + + {/* 선택한 날짜시간 미리보기 */} + {date && ( + <div className="rounded-md bg-muted p-3"> + <p className="text-sm font-medium">선택한 마감일시:</p> + <p className="text-sm text-muted-foreground"> + {format(date, "yyyy년 MM월 dd일", { locale: ko })} {time} + </p> + </div> + )} + </div> + <DialogFooter> + <Button + variant="outline" + onClick={() => setOpen(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSave} + disabled={isLoading} + > + {isLoading ? "저장 중..." : "저장"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + }
\ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index ac7104df..7470428f 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3,12 +3,20 @@ import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView, vendorContacts,projects} from "@/db/schema"; +import { 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 { 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"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { sendEmail } from "../mail/sendEmail"; +import fs from 'fs/promises' +import path from 'path' +import { addDays, format } from "date-fns" +import { ko, enUS } from "date-fns/locale" +import { generateBasicContractsForVendor } from "../basic-contract/gen-service"; +import { writeFile, mkdir } from "fs/promises"; + export async function getRfqs(input: GetRfqsSchema) { unstable_noStore(); @@ -141,6 +149,9 @@ export async function getRfqs(input: GetRfqsSchema) { } } +const isDevelopment = process.env.NODE_ENV === 'development' || + process.env.NODE_ENV === 'test'; + const getRfqById = async (id: number): Promise<RfqsLastView | null> => { // 1) RFQ 단건 조회 const rfqsRes = await db @@ -215,15 +226,15 @@ export async function getRfqAllAttachments(rfqId: number) { .where(eq(rfqLastAttachments.rfqId, rfqId)) .orderBy(desc(rfqLastAttachments.createdAt)) - return { - data, - success: true + return { + data, + success: true } } catch (err) { console.error("getRfqAllAttachments error:", err) - return { - data: [], - success: false + return { + data: [], + success: false } } } @@ -740,7 +751,7 @@ export async function getRevisionHistory(attachmentId: number): Promise<{ }) .from(rfqLastAttachmentRevisions) .where(eq(rfqLastAttachmentRevisions.id, attachment.latestRevisionId)); - + originalFileName = latestRevision?.originalFileName || null; } @@ -834,33 +845,33 @@ export async function getRfqVendorAttachments(rfqId: number) { vendorResponseId: rfqLastVendorAttachments.vendorResponseId, attachmentType: rfqLastVendorAttachments.attachmentType, documentNo: rfqLastVendorAttachments.documentNo, - + // 파일 정보 fileName: rfqLastVendorAttachments.fileName, originalFileName: rfqLastVendorAttachments.originalFileName, filePath: rfqLastVendorAttachments.filePath, fileSize: rfqLastVendorAttachments.fileSize, fileType: rfqLastVendorAttachments.fileType, - + // 파일 설명 description: rfqLastVendorAttachments.description, - + // 유효기간 validFrom: rfqLastVendorAttachments.validFrom, validTo: rfqLastVendorAttachments.validTo, - + // 업로드 정보 uploadedBy: rfqLastVendorAttachments.uploadedBy, uploadedAt: rfqLastVendorAttachments.uploadedAt, - + // 업로더 정보 uploadedByName: users.name, - + // 벤더 정보 vendorId: rfqLastVendorResponses.vendorId, vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, - + // 응답 상태 responseStatus: rfqLastVendorResponses.status, responseVersion: rfqLastVendorResponses.responseVersion, @@ -876,7 +887,7 @@ export async function getRfqVendorAttachments(rfqId: number) { .orderBy(desc(rfqLastVendorAttachments.uploadedAt)) return { - vendorData, + vendorData: data, vendorSuccess: true } } catch (err) { @@ -994,7 +1005,7 @@ export async function addVendorToRfq({ }); revalidatePath(`/rfq-last/${rfqId}/vendor`); - + return { success: true }; } catch (error) { console.error("Add vendor error:", error); @@ -1006,6 +1017,7 @@ export async function addVendorsToRfq({ rfqId, vendorIds, conditions, + contractRequirements, // 추가된 파라미터 }: { rfqId: number; vendorIds: number[]; @@ -1025,21 +1037,26 @@ export async function addVendorsToRfq({ firstDescription?: string; sparepartDescription?: string; } | null; + contractRequirements?: { // 추가된 타입 정의 + agreementYn?: boolean; + ndaYn?: boolean; + gtcType?: "general" | "project" | "none"; + } | null; }) { try { - const session = await getServerSession(authOptions) - + const session = await getServerSession(authOptions); + if (!session?.user) { - throw new Error("인증이 필요합니다.") + throw new Error("인증이 필요합니다."); } - - const userId = Number(session.user.id) - + + const userId = Number(session.user.id); + // 빈 배열 체크 if (!vendorIds || vendorIds.length === 0) { return { success: false, error: "벤더를 선택해주세요." }; } - + // 중복 체크 - 이미 추가된 벤더들 확인 const existingVendors = await db .select({ @@ -1052,25 +1069,40 @@ export async function addVendorsToRfq({ inArray(rfqLastDetails.vendorsId, vendorIds) ) ); - + const existingVendorIds = existingVendors.map(v => v.vendorId); const newVendorIds = vendorIds.filter(id => !existingVendorIds.includes(id)); - + if (newVendorIds.length === 0) { - return { - success: false, - error: "모든 벤더가 이미 추가되어 있습니다." + return { + success: false, + error: "모든 벤더가 이미 추가되어 있습니다." }; } - + // 일부만 중복인 경우 경고 메시지 준비 const skippedCount = vendorIds.length - newVendorIds.length; - + // 트랜잭션으로 처리 const results = await db.transaction(async (tx) => { const addedVendors = []; - + for (const vendorId of newVendorIds) { + // 벤더 정보 조회 (국가 정보 확인용) + const [vendor] = await tx + .select({ + id: vendors.id, + country: vendors.country, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .limit(1); + + // 국외 업체인지 확인 + const isInternational = vendor?.country && + vendor.country !== "KR" && + vendor.country !== "한국"; + // conditions가 없는 경우 기본값 설정 const vendorConditions = conditions || { currency: "USD", @@ -1079,94 +1111,79 @@ export async function addVendorsToRfq({ deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후 taxCode: "VV", }; - - // 1. rfqLastDetails에 벤더 추가 + + // contractRequirements 기본값 설정 + const defaultContractRequirements = { + agreementYn: true, + ndaYn: true, + gtcType: "none" as "general" | "project" | "none", + }; + + const finalContractRequirements = contractRequirements || defaultContractRequirements; + + // gtcType에 따라 generalGtcYn과 projectGtcYn 설정 + const generalGtcYn = isInternational && finalContractRequirements.gtcType === "general"; + const projectGtcYn = isInternational && finalContractRequirements.gtcType === "project"; + + // 국내 업체는 gtcType을 강제로 "none"으로 설정 + const gtcType = isInternational ? finalContractRequirements.gtcType : "none"; + + // 1. rfqLastDetails에 벤더 추가 (기본계약 정보 포함) const [detail] = await tx .insert(rfqLastDetails) .values({ rfqsLastId: rfqId, vendorsId: vendorId, ...vendorConditions, + // 기본계약 관련 필드 추가 + agreementYn: finalContractRequirements.agreementYn ?? true, + ndaYn: finalContractRequirements.ndaYn ?? true, + gtcType: gtcType, + generalGtcYn: generalGtcYn, + projectGtcYn: projectGtcYn, updatedBy: userId, + updatedAt: new Date(), }) .returning(); - - // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 - const [response] = await tx - .insert(rfqLastVendorResponses) - .values({ - rfqsLastId: rfqId, - rfqLastDetailsId: detail.id, - vendorId: vendorId, - status: "초대됨", - responseVersion: 1, - isLatest: true, - currency: vendorConditions.currency, - // 구매자 제시 조건 복사 (초기값) - vendorCurrency: vendorConditions.currency, - vendorPaymentTermsCode: vendorConditions.paymentTermsCode, - vendorIncotermsCode: vendorConditions.incotermsCode, - vendorIncotermsDetail: vendorConditions.incotermsDetail, - vendorDeliveryDate: vendorConditions.deliveryDate, - vendorContractDuration: vendorConditions.contractDuration, - vendorTaxCode: vendorConditions.taxCode, - vendorPlaceOfShipping: vendorConditions.placeOfShipping, - vendorPlaceOfDestination: vendorConditions.placeOfDestination, - vendorMaterialPriceRelatedYn: vendorConditions.materialPriceRelatedYn, - vendorSparepartYn: vendorConditions.sparepartYn, - vendorFirstYn: vendorConditions.firstYn, - vendorFirstDescription: vendorConditions.firstDescription, - vendorSparepartDescription: vendorConditions.sparepartDescription, - createdBy: userId, - updatedBy: userId, - }) - .returning(); - - // 3. 이력 기록 - await tx.insert(rfqLastVendorResponseHistory).values({ - vendorResponseId: response.id, - action: "생성", - newStatus: "초대됨", - changeDetails: { - action: "벤더 초대", - conditions: vendorConditions, - batchAdd: true, - totalVendors: newVendorIds.length - }, - performedBy: userId, - }); - + addedVendors.push({ vendorId, detailId: detail.id, - responseId: response.id, + contractRequirements: { + agreementYn: detail.agreementYn, + ndaYn: detail.ndaYn, + gtcType: detail.gtcType, + generalGtcYn: detail.generalGtcYn, + projectGtcYn: detail.projectGtcYn, + } }); } - + return addedVendors; }); - - revalidatePath(`/rfq-last/${rfqId}/vendor`); - + + revalidatePath(`/evcp/rfq-last/${rfqId}/vendor`); + // 성공 메시지 구성 let message = `${results.length}개 벤더가 추가되었습니다.`; if (skippedCount > 0) { message += ` (${skippedCount}개는 이미 추가된 벤더로 제외)`; } - - return { + + return { success: true, data: { added: results.length, skipped: skippedCount, message, + vendors: results, // 추가된 벤더 정보 반환 } }; } catch (error) { console.error("Add vendors error:", error); - return { - success: false, - error: "벤더 추가 중 오류가 발생했습니다." + return { + success: false, + error: "벤더 추가 중 오류가 발생했습니다." }; } } @@ -1198,17 +1215,17 @@ export async function updateVendorConditionsBatch({ }) { try { const session = await getServerSession(authOptions) - + if (!session?.user) { throw new Error("인증이 필요합니다.") } - + const userId = Number(session.user.id) - + if (!vendorIds || vendorIds.length === 0) { return { success: false, error: "벤더를 선택해주세요." }; } - + // 트랜잭션으로 처리 await db.transaction(async (tx) => { // 1. rfqLastDetails 업데이트 @@ -1225,7 +1242,7 @@ export async function updateVendorConditionsBatch({ inArray(rfqLastDetails.vendorsId, vendorIds) ) ); - + // 2. rfqLastVendorResponses의 구매자 제시 조건도 업데이트 const vendorConditions = Object.keys(conditions).reduce((acc, key) => { if (conditions[key] !== undefined) { @@ -1233,7 +1250,7 @@ export async function updateVendorConditionsBatch({ } return acc; }, {}); - + await tx .update(rfqLastVendorResponses) .set({ @@ -1248,7 +1265,7 @@ export async function updateVendorConditionsBatch({ eq(rfqLastVendorResponses.isLatest, true) ) ); - + // 3. 이력 기록 (각 벤더별로) const responses = await tx .select({ id: rfqLastVendorResponses.id }) @@ -1260,25 +1277,25 @@ export async function updateVendorConditionsBatch({ eq(rfqLastVendorResponses.isLatest, true) ) ); - + for (const response of responses) { await tx.insert(rfqLastVendorResponseHistory).values({ vendorResponseId: response.id, action: "조건변경", - changeDetails: { - action: "조건 일괄 업데이트", + changeDetails: { + action: "조건 일괄 업데이트", conditions, batchUpdate: true, - totalVendors: vendorIds.length + totalVendors: vendorIds.length }, performedBy: userId, }); } }); - + revalidatePath(`/rfq-last/${rfqId}/vendor`); - - return { + + return { success: true, data: { message: `${vendorIds.length}개 벤더의 조건이 업데이트되었습니다.` @@ -1286,77 +1303,13 @@ export async function updateVendorConditionsBatch({ }; } catch (error) { console.error("Update vendor conditions error:", error); - return { - success: false, - error: "조건 업데이트 중 오류가 발생했습니다." + return { + success: false, + error: "조건 업데이트 중 오류가 발생했습니다." }; } } -// RFQ 발송 액션 -export async function sendRfqToVendors({ - rfqId, - vendorIds, -}: { - rfqId: number; - vendorIds: number[]; -}) { - try { - - const session = await getServerSession(authOptions) - - if (!session?.user) { - throw new Error("인증이 필요합니다.") - } - const userId = Number(session.user.id) - - // 벤더별 응답 상태 업데이트 - for (const vendorId of vendorIds) { - const [response] = await db - .select() - .from(rfqLastVendorResponses) - .where( - and( - eq(rfqLastVendorResponses.rfqsLastId, rfqId), - eq(rfqLastVendorResponses.vendorId, vendorId), - eq(rfqLastVendorResponses.isLatest, true) - ) - ) - .limit(1); - - if (response) { - // 상태 업데이트 - await db - .update(rfqLastVendorResponses) - .set({ - status: "작성중", - updatedBy: userId, - updatedAt: new Date(), - }) - .where(eq(rfqLastVendorResponses.id, response.id)); - - // 이력 기록 - await db.insert(rfqLastVendorResponseHistory).values({ - vendorResponseId: response.id, - action: "발송", - previousStatus: response.status, - newStatus: "작성중", - changeDetails: { action: "RFQ 발송" }, - performedBy: userId, - }); - } - } - - // TODO: 실제 이메일 발송 로직 - - revalidatePath(`/rfq-last/${rfqId}/vendor`); - - return { success: true, count: vendorIds.length }; - } catch (error) { - console.error("Send RFQ error:", error); - return { success: false, error: "RFQ 발송 중 오류가 발생했습니다." }; - } -} // 벤더 삭제 액션 export async function removeVendorFromRfq({ @@ -1387,9 +1340,9 @@ export async function removeVendorFromRfq({ .limit(1); if (response && response.status !== "초대됨") { - return { - success: false, - error: "이미 진행 중인 벤더는 삭제할 수 없습니다." + return { + success: false, + error: "이미 진행 중인 벤더는 삭제할 수 없습니다." }; } @@ -1404,7 +1357,7 @@ export async function removeVendorFromRfq({ ); revalidatePath(`/rfq-last/${rfqId}/vendor`); - + return { success: true }; } catch (error) { console.error("Remove vendor error:", error); @@ -1462,7 +1415,7 @@ export async function updateVendorResponseStatus({ }); revalidatePath(`/evcp/rfq-last/${current.rfqsLastId}/vendor`); - + return { success: true }; } catch (error) { console.error("Update status error:", error); @@ -1488,20 +1441,19 @@ export async function getRfqVendorResponses(rfqId: number) { .select({ id: rfqsLast.id, rfqCode: rfqsLast.rfqCode, - title: rfqsLast.title, + title: rfqsLast.rfqTitle, status: rfqsLast.status, - startDate: rfqsLast.startDate, - endDate: rfqsLast.endDate, + endDate: rfqsLast.dueDate, }) .from(rfqsLast) .where(eq(rfqsLast.id, rfqId)) .limit(1); if (!rfqData || rfqData.length === 0) { - return { - success: false, + return { + success: false, error: "RFQ를 찾을 수 없습니다.", - data: null + data: null }; } @@ -1510,7 +1462,7 @@ export async function getRfqVendorResponses(rfqId: number) { .select() .from(rfqLastDetails) .where(eq(rfqLastDetails.rfqsLastId, rfqId)) - .orderBy(desc(rfqLastDetails.version)); + .orderBy(desc(rfqLastDetails.updatedAt)); // 3. 벤더 응답 정보 조회 (벤더 정보, 제출자 정보 포함) const vendorResponsesData = await db @@ -1522,39 +1474,61 @@ export async function getRfqVendorResponses(rfqId: number) { responseVersion: rfqLastVendorResponses.responseVersion, isLatest: rfqLastVendorResponses.isLatest, status: rfqLastVendorResponses.status, - + + //참여 정보 + participationStatus: rfqLastVendorResponses.participationStatus, + participationRepliedAt: rfqLastVendorResponses.participationRepliedAt, + participationRepliedBy: rfqLastVendorResponses.participationRepliedBy, + nonParticipationReason: rfqLastVendorResponses.nonParticipationReason, + // 벤더 정보 vendorId: rfqLastVendorResponses.vendorId, vendorCode: vendors.vendorCode, vendorName: vendors.vendorName, vendorEmail: vendors.email, - + // 제출 정보 submittedAt: rfqLastVendorResponses.submittedAt, submittedBy: rfqLastVendorResponses.submittedBy, submittedByName: users.name, - + // 금액 정보 totalAmount: rfqLastVendorResponses.totalAmount, currency: rfqLastVendorResponses.currency, - + // 벤더 제안 조건 vendorCurrency: rfqLastVendorResponses.vendorCurrency, vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode, vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode, + vendorIncotermsDetail: rfqLastVendorResponses.vendorIncotermsDetail, vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate, vendorContractDuration: rfqLastVendorResponses.vendorContractDuration, - - // 초도품/Spare part 응답 + vendorTaxCode: rfqLastVendorResponses.vendorTaxCode, + vendorPlaceOfShipping: rfqLastVendorResponses.vendorPlaceOfShipping, + vendorPlaceOfDestination: rfqLastVendorResponses.vendorPlaceOfDestination, + + // 초도품/Spare part/연동제 응답 vendorFirstYn: rfqLastVendorResponses.vendorFirstYn, + vendorFirstDescription: rfqLastVendorResponses.vendorFirstDescription, vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance, vendorSparepartYn: rfqLastVendorResponses.vendorSparepartYn, + vendorSparepartDescription: rfqLastVendorResponses.vendorSparepartDescription, vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance, - + vendorMaterialPriceRelatedYn: rfqLastVendorResponses.vendorMaterialPriceRelatedYn, + vendorMaterialPriceRelatedReason: rfqLastVendorResponses.vendorMaterialPriceRelatedReason, + + // 변경 사유 + currencyReason: rfqLastVendorResponses.currencyReason, + paymentTermsReason: rfqLastVendorResponses.paymentTermsReason, + deliveryDateReason: rfqLastVendorResponses.deliveryDateReason, + incotermsReason: rfqLastVendorResponses.incotermsReason, + taxReason: rfqLastVendorResponses.taxReason, + shippingReason: rfqLastVendorResponses.shippingReason, + // 비고 generalRemark: rfqLastVendorResponses.generalRemark, technicalProposal: rfqLastVendorResponses.technicalProposal, - + // 타임스탬프 createdAt: rfqLastVendorResponses.createdAt, updatedAt: rfqLastVendorResponses.updatedAt, @@ -1570,108 +1544,269 @@ export async function getRfqVendorResponses(rfqId: number) { ) .orderBy(desc(rfqLastVendorResponses.createdAt)); - if (!vendorResponsesData || vendorResponsesData.length === 0) { - return { - success: true, - data: [], - rfq: rfqData[0], - details: details, - }; - } + if (!vendorResponsesData || vendorResponsesData.length === 0) { + return { + success: true, + data: [], + rfq: rfqData[0], + details: details, + }; + } - // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산 - const vendorResponsesWithCounts = await Promise.all( + // 4. 각 벤더별 총 응답 수 조회 (모든 버전 포함) + const vendorResponseCounts = await db + .select({ + vendorId: rfqLastVendorResponses.vendorId, + responseCount: count(), + }) + .from(rfqLastVendorResponses) + .where(eq(rfqLastVendorResponses.rfqsLastId, rfqId)) + .groupBy(rfqLastVendorResponses.vendorId); + + // vendorId를 키로 하는 Map 생성 + const responseCountMap = new Map( + vendorResponseCounts.map(item => [item.vendorId, item.responseCount]) + ); + + // 5. 각 벤더 응답별 상세 정보 조회 (견적 아이템, 첨부파일) + const vendorResponsesWithDetails = await Promise.all( vendorResponsesData.map(async (response) => { - // 견적 아이템 수 조회 - const itemCount = await db - .select({ count: count()}) + // 견적 아이템 상세 조회 + const quotationItems = await db + .select({ + id: rfqLastVendorQuotationItems.id, + vendorResponseId: rfqLastVendorQuotationItems.vendorResponseId, + rfqPrItemId: rfqLastVendorQuotationItems.rfqPrItemId, + + // PR 아이템 정보 + prNo: rfqLastVendorQuotationItems.prNo, + materialCode: rfqLastVendorQuotationItems.materialCode, + materialDescription: rfqLastVendorQuotationItems.materialDescription, + + // 견적 정보 + quantity: rfqLastVendorQuotationItems.quantity, + uom: rfqLastVendorQuotationItems.uom, + unitPrice: rfqLastVendorQuotationItems.unitPrice, + totalPrice: rfqLastVendorQuotationItems.totalPrice, + currency: rfqLastVendorQuotationItems.currency, + + // 납기 정보 + vendorDeliveryDate: rfqLastVendorQuotationItems.vendorDeliveryDate, + leadTime: rfqLastVendorQuotationItems.leadTime, + + // 제조사 정보 + manufacturer: rfqLastVendorQuotationItems.manufacturer, + manufacturerCountry: rfqLastVendorQuotationItems.manufacturerCountry, + modelNo: rfqLastVendorQuotationItems.modelNo, + + // 기술 사양 + technicalCompliance: rfqLastVendorQuotationItems.technicalCompliance, + alternativeProposal: rfqLastVendorQuotationItems.alternativeProposal, + + // 할인 정보 + discountRate: rfqLastVendorQuotationItems.discountRate, + discountAmount: rfqLastVendorQuotationItems.discountAmount, + + // 비고 + itemRemark: rfqLastVendorQuotationItems.itemRemark, + deviationReason: rfqLastVendorQuotationItems.deviationReason, + + createdAt: rfqLastVendorQuotationItems.createdAt, + updatedAt: rfqLastVendorQuotationItems.updatedAt, + }) .from(rfqLastVendorQuotationItems) - .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)); - - // 첨부파일 수 조회 - const attachmentCount = await db - .select({ count: count()}) + .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)) + .orderBy(rfqLastVendorQuotationItems.id); + + // 첨부파일 조회 + const attachments = await db + .select({ + id: rfqLastVendorAttachments.id, + vendorResponseId: rfqLastVendorAttachments.vendorResponseId, + + // 첨부파일 구분 + attachmentType: rfqLastVendorAttachments.attachmentType, + documentNo: rfqLastVendorAttachments.documentNo, + + // 파일 정보 + fileName: rfqLastVendorAttachments.fileName, + originalFileName: rfqLastVendorAttachments.originalFileName, + filePath: rfqLastVendorAttachments.filePath, + fileSize: rfqLastVendorAttachments.fileSize, + fileType: rfqLastVendorAttachments.fileType, + + // 파일 설명 + description: rfqLastVendorAttachments.description, + + // 유효기간 (인증서 등) + validFrom: rfqLastVendorAttachments.validFrom, + validTo: rfqLastVendorAttachments.validTo, + + // 업로드 정보 + uploadedBy: rfqLastVendorAttachments.uploadedBy, + uploadedByName: users.name, + uploadedAt: rfqLastVendorAttachments.uploadedAt, + }) .from(rfqLastVendorAttachments) - .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)); + .leftJoin(users, eq(rfqLastVendorAttachments.uploadedBy, users.id)) + .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)) + .orderBy(rfqLastVendorAttachments.attachmentType, rfqLastVendorAttachments.uploadedAt); + + // 해당 벤더의 총 응답 수 가져오기 + const vendorResponseCount = responseCountMap.get(response.vendorId) || 0; return { ...response, - quotedItemCount: itemCount[0]?.count || 0, - attachmentCount: attachmentCount[0]?.count || 0, + quotationItems, + attachments, + vendorResponseCount, }; }) ); - // 5. 응답 데이터 정리 - const formattedResponses = vendorResponsesWithCounts - .filter(response => response && response.id).map(response => ({ - id: response.id, - rfqsLastId: response.rfqsLastId, - rfqLastDetailsId: response.rfqLastDetailsId, - responseVersion: response.responseVersion, - isLatest: response.isLatest, - status: response.status || "초대됨", // 기본값 설정 - - // 벤더 정보 - vendor: { - id: response.vendorId, - code: response.vendorCode, - name: response.vendorName, - email: response.vendorEmail, - }, - - // 제출 정보 - submission: { - submittedAt: response.submittedAt, - submittedBy: response.submittedBy, - submittedByName: response.submittedByName, - }, - - // 금액 정보 - pricing: { - totalAmount: response.totalAmount, - currency: response.currency || "USD", - vendorCurrency: response.vendorCurrency, - }, - - // 벤더 제안 조건 - vendorTerms: { - paymentTermsCode: response.vendorPaymentTermsCode, - incotermsCode: response.vendorIncotermsCode, - deliveryDate: response.vendorDeliveryDate, - contractDuration: response.vendorContractDuration, - }, - - // 초도품/Spare part - additionalRequirements: { - firstArticle: { - required: response.vendorFirstYn, - acceptance: response.vendorFirstAcceptance, + // 6. 응답 데이터 정리 + const formattedResponses = vendorResponsesWithDetails + .filter(response => response && response.id) + .map(response => ({ + id: response.id, + rfqsLastId: response.rfqsLastId, + rfqLastDetailsId: response.rfqLastDetailsId, + responseVersion: response.responseVersion, + isLatest: response.isLatest, + status: response.status, + + // 벤더 정보 + vendor: { + id: response.vendorId, + code: response.vendorCode, + name: response.vendorName, + email: response.vendorEmail, + responseCount: response.vendorResponseCount, }, - sparePart: { - required: response.vendorSparepartYn, - acceptance: response.vendorSparepartAcceptance, + + // 제출 정보 + submission: { + submittedAt: response.submittedAt, + submittedBy: response.submittedBy, + submittedByName: response.submittedByName, }, - }, - - // 카운트 정보 - counts: { - quotedItems: response.quotedItemCount, - attachments: response.attachmentCount, - }, - - // 비고 - remarks: { - general: response.generalRemark, - technical: response.technicalProposal, - }, - - // 타임스탬프 - timestamps: { - createdAt: response.createdAt, - updatedAt: response.updatedAt, - }, - })); + + // 제출 정보 + attend: { + participationStatus: response.participationStatus, + participationRepliedAt: response.participationRepliedAt, + participationRepliedBy: response.participationRepliedBy, + nonParticipationReason: response.nonParticipationReason, + }, + + // 금액 정보 + pricing: { + totalAmount: response.totalAmount, + currency: response.currency || "USD", + vendorCurrency: response.vendorCurrency, + }, + + // 벤더 제안 조건 + vendorTerms: { + paymentTermsCode: response.vendorPaymentTermsCode, + incotermsCode: response.vendorIncotermsCode, + incotermsDetail: response.vendorIncotermsDetail, + deliveryDate: response.vendorDeliveryDate, + contractDuration: response.vendorContractDuration, + taxCode: response.vendorTaxCode, + placeOfShipping: response.vendorPlaceOfShipping, + placeOfDestination: response.vendorPlaceOfDestination, + }, + + // 초도품/Spare part/연동제 + additionalRequirements: { + firstArticle: { + required: response.vendorFirstYn, + description: response.vendorFirstDescription, + acceptance: response.vendorFirstAcceptance, + }, + sparePart: { + required: response.vendorSparepartYn, + description: response.vendorSparepartDescription, + acceptance: response.vendorSparepartAcceptance, + }, + materialPriceRelated: { + required: response.vendorMaterialPriceRelatedYn, + reason: response.vendorMaterialPriceRelatedReason, + }, + }, + + // 변경 사유 + changeReasons: { + currency: response.currencyReason, + paymentTerms: response.paymentTermsReason, + deliveryDate: response.deliveryDateReason, + incoterms: response.incotermsReason, + tax: response.taxReason, + shipping: response.shippingReason, + }, + + // 카운트 정보 + counts: { + quotedItems: response.quotationItems.length, + attachments: response.attachments.length, + }, + + // 비고 + remarks: { + general: response.generalRemark, + technical: response.technicalProposal, + }, + + // 견적 아이템 상세 + quotationItems: response.quotationItems.map(item => ({ + id: item.id, + rfqPrItemId: item.rfqPrItemId, + prNo: item.prNo, + materialCode: item.materialCode, + materialDescription: item.materialDescription, + quantity: item.quantity, + uom: item.uom, + unitPrice: item.unitPrice, + totalPrice: item.totalPrice, + currency: item.currency, + vendorDeliveryDate: item.vendorDeliveryDate, + leadTime: item.leadTime, + manufacturer: item.manufacturer, + manufacturerCountry: item.manufacturerCountry, + modelNo: item.modelNo, + technicalCompliance: item.technicalCompliance, + alternativeProposal: item.alternativeProposal, + discountRate: item.discountRate, + discountAmount: item.discountAmount, + itemRemark: item.itemRemark, + deviationReason: item.deviationReason, + })), + + // 첨부파일 상세 + attachments: response.attachments.map(file => ({ + id: file.id, + attachmentType: file.attachmentType, + documentNo: file.documentNo, + fileName: file.fileName, + originalFileName: file.originalFileName, + filePath: file.filePath, // 파일 경로 포함 + fileSize: file.fileSize, + fileType: file.fileType, + description: file.description, + validFrom: file.validFrom, + validTo: file.validTo, + uploadedBy: file.uploadedBy, + uploadedByName: file.uploadedByName, + uploadedAt: file.uploadedAt, + })), + + // 타임스탬프 + timestamps: { + createdAt: response.createdAt, + updatedAt: response.updatedAt, + }, + })); return { success: true, @@ -1706,11 +1841,16 @@ export async function getRfqWithDetails(rfqId: number) { const details = await db .select() .from(rfqLastDetailsView) - .where(eq(rfqLastDetailsView.rfqId, rfqId)) + .where( + and( + eq(rfqLastDetailsView.rfqId, rfqId), + eq(rfqLastDetailsView.isLatest, true) // isLatest 필터 추가 + ) + ) .orderBy(desc(rfqLastDetailsView.detailId)); - return { - success: true, + return { + success: true, data: { // RFQ 기본 정보 (rfqsLastView에서 제공) id: rfqData.id, @@ -1719,58 +1859,58 @@ export async function getRfqWithDetails(rfqId: number) { rfqTitle: rfqData.rfqTitle, series: rfqData.series, rfqSealedYn: rfqData.rfqSealedYn, - + // ITB 관련 projectCompany: rfqData.projectCompany, projectFlag: rfqData.projectFlag, projectSite: rfqData.projectSite, smCode: rfqData.smCode, - + // PR 정보 prNumber: rfqData.prNumber, prIssueDate: rfqData.prIssueDate, - + // 프로젝트 정보 projectId: rfqData.projectId, projectCode: rfqData.projectCode, projectName: rfqData.projectName, - + // 아이템 정보 itemCode: rfqData.itemCode, itemName: rfqData.itemName, - + // 패키지 정보 packageNo: rfqData.packageNo, packageName: rfqData.packageName, - + // 날짜 및 상태 dueDate: rfqData.dueDate, rfqSendDate: rfqData.rfqSendDate, status: rfqData.status, - + // PIC 정보 picId: rfqData.picId, picCode: rfqData.picCode, picName: rfqData.picName, picUserName: rfqData.picUserName, engPicName: rfqData.engPicName, - + // 집계 정보 (View에서 이미 계산됨) vendorCount: rfqData.vendorCount, shortListedVendorCount: rfqData.shortListedVendorCount, quotationReceivedCount: rfqData.quotationReceivedCount, prItemsCount: rfqData.prItemsCount, majorItemsCount: rfqData.majorItemsCount, - + // 견적 제출 정보 earliestQuotationSubmittedAt: rfqData.earliestQuotationSubmittedAt, - + // Major Item 정보 majorItemMaterialCode: rfqData.majorItemMaterialCode, majorItemMaterialDescription: rfqData.majorItemMaterialDescription, majorItemMaterialCategory: rfqData.majorItemMaterialCategory, majorItemPrNo: rfqData.majorItemPrNo, - + // 감사 정보 createdBy: rfqData.createdBy, createdByUserName: rfqData.createdByUserName, @@ -1780,20 +1920,20 @@ export async function getRfqWithDetails(rfqId: number) { updatedBy: rfqData.updatedBy, updatedByUserName: rfqData.updatedByUserName, updatedAt: rfqData.updatedAt, - + // 비고 remark: rfqData.remark, - + // 벤더별 상세 조건 (rfqLastDetailsView에서 제공) details: details.map(d => ({ detailId: d.detailId, - + // 벤더 정보 vendorId: d.vendorId, vendorName: d.vendorName, vendorCode: d.vendorCode, vendorCountry: d.vendorCountry, - + // 조건 정보 currency: d.currency, paymentTermsCode: d.paymentTermsCode, @@ -1806,7 +1946,7 @@ export async function getRfqWithDetails(rfqId: number) { taxCode: d.taxCode, placeOfShipping: d.placeOfShipping, placeOfDestination: d.placeOfDestination, - + // Boolean 필드들 shortList: d.shortList, returnYn: d.returnYn, @@ -1818,13 +1958,13 @@ export async function getRfqWithDetails(rfqId: number) { materialPriceRelatedYn: d.materialPriceRelatedYn, sparepartYn: d.sparepartYn, firstYn: d.firstYn, - + // 설명 필드 firstDescription: d.firstDescription, sparepartDescription: d.sparepartDescription, remark: d.remark, cancelReason: d.cancelReason, - + // 견적 관련 정보 (View에서 이미 계산됨) hasQuotation: d.hasQuotation, quotationStatus: d.quotationStatus, @@ -1833,11 +1973,18 @@ export async function getRfqWithDetails(rfqId: number) { quotationVersionCount: d.quotationVersionCount, lastQuotationDate: d.lastQuotationDate, quotationSubmittedAt: d.quotationSubmittedAt, - + // 감사 정보 updatedBy: d.updatedBy, updatedByUserName: d.updatedByUserName, updatedAt: d.updatedAt, + + sendVersion: d.sendVersion, + emailSentAt: d.emailSentAt, + emailSentTo: d.emailSentTo, + emailResentCount: d.emailResentCount, + lastEmailSentAt: d.lastEmailSentAt, + emailStatus: d.emailStatus, })), } }; @@ -1857,59 +2004,59 @@ export interface RfqFullInfo { 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; @@ -1917,17 +2064,17 @@ export interface RfqFullInfo { 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[]; @@ -1944,7 +2091,7 @@ export interface VendorDetail { vendorCategory?: string | null; vendorGrade?: string | null; basicContract?: string | null; - + // RFQ 조건 currency: string | null; paymentTermsCode: string | null; @@ -1957,32 +2104,32 @@ export interface VendorDetail { 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; @@ -1996,14 +2143,14 @@ export interface AttachmentInfo { 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; @@ -2075,7 +2222,7 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { 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, @@ -2087,25 +2234,25 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { 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, @@ -2135,13 +2282,13 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { 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, @@ -2152,13 +2299,13 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { 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) @@ -2173,19 +2320,19 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { .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 + + const [sentByUser] = rfq.sentBy ? await db - .select({ name: users.name }) - .from(users) - .where(eq(users.id, rfq.sentBy)) - .limit(1) + .select({ name: users.name }) + .from(users) + .where(eq(users.id, rfq.sentBy)) + .limit(1) : [null]; // 7. 전체 정보 조합 @@ -2197,59 +2344,59 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { 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, @@ -2259,14 +2406,14 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { 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, @@ -2284,7 +2431,7 @@ export async function getRfqFullInfo(rfqId: number): Promise<RfqFullInfo> { */ export async function getRfqInfoForSend(rfqId: number) { const fullInfo = await getRfqFullInfo(rfqId); - + return { rfqCode: fullInfo.rfqCode, rfqTitle: fullInfo.rfqTitle || '', @@ -2369,6 +2516,7 @@ export interface VendorEmailInfo { contactEmails: string[]; // 영업/대표 담당자 이메일들 primaryEmail?: string | null; // 최종 선택된 주 이메일 currency?: string | null; + currency?: string | null; } /** @@ -2448,9 +2596,6 @@ export async function getRfqSendData(rfqId: number): Promise<RfqSendData> { packageNo: rfq.packageNo || undefined, packageName: rfq.packageName || undefined, designPicName: rfq.EngPicName || undefined, - rfqTitle: rfq.rfqTitle || undefined, - rfqType: rfq.rfqType || undefined, - designTeam: undefined, // 필요시 추가 조회 materialGroup: majorItem?.materialCategory || undefined, materialGroupDesc: majorItem?.materialDescription || undefined, dueDate: rfq.dueDate || new Date(), @@ -2515,7 +2660,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma // 3. 데이터 조합 const vendorEmailInfos: VendorEmailInfo[] = vendorsData.map(vendor => { const vendorContacts = contactsData.filter(c => c.vendorId === vendor.id); - + // ContactDetail 형식으로 변환 const contacts: ContactDetail[] = vendorContacts.map(c => ({ id: c.id, @@ -2526,7 +2671,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma phone: c.contactPhone, isPrimary: c.isPrimary, })); - + // 포지션별로 그룹화 const contactsByPosition: Record<string, ContactDetail[]> = {}; contacts.forEach(contact => { @@ -2536,7 +2681,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma } contactsByPosition[position].push(contact); }); - + // 주 이메일 선택 우선순위: // 1. isPrimary가 true인 담당자 이메일 // 2. 대표자 이메일 @@ -2545,7 +2690,7 @@ export async function getVendorEmailInfo(vendorIds: number[]): Promise<VendorEma // 5. 첫번째 담당자 이메일 const primaryContact = contacts.find(c => c.isPrimary); const salesContact = contacts.find(c => c.position === '영업'); - const primaryEmail = + const primaryEmail = primaryContact?.email || vendor.representativeEmail || vendor.email || @@ -2584,17 +2729,23 @@ export async function getSelectedVendorsWithEmails( try { // 1. 벤더 이메일 정보 조회 const vendorEmailInfos = await getVendorEmailInfo(vendorIds); - + // 2. RFQ Detail에서 통화 정보 조회 (옵션) const rfqDetailsData = await db .select({ vendorId: rfqLastDetails.vendorsId, currency: rfqLastDetails.currency, + ndaYn: rfqLastDetails.ndaYn, + generalGtcYn: rfqLastDetails.generalGtcYn, + projectGtcYn: rfqLastDetails.projectGtcYn, + agreementYn: rfqLastDetails.agreementYn, + sendVersion: rfqLastDetails.sendVersion, }) .from(rfqLastDetails) .where( and( eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.isLatest, true), sql`${rfqLastDetails.vendorsId} IN ${vendorIds}` ) ); @@ -2605,6 +2756,11 @@ export async function getSelectedVendorsWithEmails( return { ...vendor, currency: detail?.currency || vendor.currency || 'KRW', + ndaYn: detail?.ndaYn, + generalGtcYn: detail?.generalGtcYn, + projectGtcYn: detail?.projectGtcYn, + agreementYn: detail?.agreementYn, + sendVersion: detail?.sendVersion }; }); @@ -2613,4 +2769,977 @@ export async function getSelectedVendorsWithEmails( console.error("선택된 벤더 정보 조회 실패:", error); throw error; } +} + +interface SendRfqVendor { + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails: Array<{ id: string; email: string; name?: string }>; +} + +export interface ContractRequirements { + ndaYn: boolean; + generalGtcYn: boolean; + projectGtcYn: boolean; + agreementYn: boolean; + projectCode?: string; // Project GTC를 위한 프로젝트 코드 +} + +export interface VendorForSend { + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails?: Array<{ email: string; name?: string }>; + currency?: string | null; + + // 기본계약 관련 + contractRequirements?: ContractRequirements; + + // 재발송 관련 + isResend: boolean; + sendVersion?: number; +} + +export interface SendRfqParams { + rfqId: number; + rfqCode?: string; + vendors: VendorForSend[]; + attachmentIds: number[]; + message?: string; +} + +export async function sendRfqToVendors({ + rfqId, + rfqCode, + vendors, + attachmentIds, + message, + generatedPdfs +}: SendRfqParams & { + generatedPdfs?: Array<{ + key: string; + buffer: number[]; + fileName: string; + }>; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const currentUser = session.user + + // 트랜잭션 시작 + const result = await db.transaction(async (tx) => { + // 1. RFQ 정보 조회 + const [rfqData] = await tx + .select({ + id: rfqsLast.id, + rfqCode: rfqsLast.rfqCode, + rfqType: rfqsLast.rfqType, + rfqTitle: rfqsLast.rfqTitle, + projectId: rfqsLast.projectId, + itemCode: rfqsLast.itemCode, + itemName: rfqsLast.itemName, + dueDate: rfqsLast.dueDate, + packageNo: rfqsLast.packageNo, + packageName: rfqsLast.packageName, + picId: rfqsLast.pic, + picCode: rfqsLast.picCode, + picName: rfqsLast.picName, + projectCompany: rfqsLast.projectCompany, + projectFlag: rfqsLast.projectFlag, + projectSite: rfqsLast.projectSite, + smCode: rfqsLast.smCode, + prNumber: rfqsLast.prNumber, + prIssueDate: rfqsLast.prIssueDate, + series: rfqsLast.series, + EngPicName: rfqsLast.EngPicName, + }) + .from(rfqsLast) + .where(eq(rfqsLast.id, rfqId)); + + if (!rfqData) { + throw new Error("RFQ 정보를 찾을 수 없습니다."); + } + + // 2. PIC 사용자 정보 조회 + let picEmail = process.env.Email_From_Address; + let picName = rfqData.picName || "구매담당자"; + + if (rfqData.picId) { + const [picUser] = await tx + .select() + .from(users) + .where(eq(users.id, rfqData.picId)); + + if (picUser?.email) { + picEmail = picUser.email; + picName = picUser.name || picName; + } + } + + // 3. 프로젝트 정보 조회 + let projectInfo = null; + if (rfqData.projectId) { + const [project] = await tx + .select() + .from(projects) + .where(eq(projects.id, rfqData.projectId)); + projectInfo = project; + } + + // 4. PR 아이템 정보 조회 + const prItems = await tx + .select() + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, rfqId)); + + // 5. 첨부파일 정보 조회 및 준비 + const attachments = await tx + .select({ + attachment: rfqLastAttachments, + revision: rfqLastAttachmentRevisions + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachmentRevisions.attachmentId, rfqLastAttachments.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .where( + and( + eq(rfqLastAttachments.rfqId, rfqId), + attachmentIds.length > 0 + ? sql`${rfqLastAttachments.id} IN (${sql.join(attachmentIds, sql`, `)})` + : sql`1=1` + ) + ); + + // 6. 이메일 첨부파일 준비 + const emailAttachments = []; + for (const { attachment, revision } of attachments) { + if (revision?.filePath) { + try { + const fullPath = path.join(process.cwd(), `${process.env.NAS_PATH}`, revision.filePath); + const fileBuffer = await fs.readFile(fullPath); + emailAttachments.push({ + filename: revision.originalFileName, + content: fileBuffer, + contentType: revision.fileType || 'application/octet-stream' + }); + } catch (error) { + console.error(`첨부파일 읽기 실패: ${revision.filePath}`, error); + } + } + } + + // ========== TBE용 설계 문서 조회 (중요!) ========== + const designAttachments = await tx + .select({ + attachment: rfqLastAttachments, + revision: rfqLastAttachmentRevisions + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachmentRevisions.attachmentId, rfqLastAttachments.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) + ) + .where( + and( + eq(rfqLastAttachments.rfqId, rfqId), + eq(rfqLastAttachments.attachmentType, "설계") // 설계 문서만 필터링 + ) + ); + + + // 7. 각 벤더별 처리 + const results = []; + const errors = []; + const savedContracts = []; + const tbeSessionsCreated = []; + + const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated"); + await mkdir(contractsDir, { recursive: true }); + + + for (const vendor of vendors) { + // 재발송 여부 확인 + const isResend = vendor.isResend || false; + const sendVersion = (vendor.sendVersion || 0) + 1; + + // 7.4 이메일 수신자 정보 준비 + const toEmails = [vendor.selectedMainEmail]; + const ccEmails = [...vendor.additionalEmails]; + + vendor.customEmails?.forEach(custom => { + if (custom.email !== vendor.selectedMainEmail && + !vendor.additionalEmails.includes(custom.email)) { + ccEmails.push(custom.email); + } + }); + + // 이메일 수신자 정보를 JSON으로 저장 + const emailRecipients = { + to: toEmails, + cc: ccEmails, + sentBy: picEmail + }; + + try { + // 7.1 rfqLastDetails 조회 또는 생성 + let [rfqDetail] = await tx + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendor.vendorId), + eq(rfqLastDetails.isLatest, true), + ) + ); + + if (!rfqDetail) { + throw new Error("해당 RFQ에는 벤더가 이미 할당되어있는 상태이어야합니다."); + } + + // 기존 rfqDetail을 isLatest=false로 업데이트 + const updateResult = await tx + .update(rfqLastDetails) + .set({ + isLatest: false, + updatedAt: new Date() // 업데이트 시간도 기록 + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendor.vendorId), + eq(rfqLastDetails.isLatest, true) + ) + ) + .returning({ id: rfqLastDetails.id }); + + console.log(`Updated ${updateResult.length} records to isLatest=false for vendor ${vendor.vendorId}`); + + const { id, updatedBy, updatedAt, isLatest, sendVersion: oldSendVersion, emailResentCount, ...restRfqDetail } = rfqDetail; + + + let [newRfqDetail] = await tx + .insert(rfqLastDetails) + .values({ + ...restRfqDetail, // 기존 값들 복사 + + // 업데이트할 필드들 + updatedBy: Number(currentUser.id), + updatedAt: new Date(), + isLatest: true, + + // 이메일 관련 필드 업데이트 + emailSentAt: new Date(), + emailSentTo: JSON.stringify(emailRecipients), + emailResentCount: isResend ? (emailResentCount || 0) + 1 : 1, + sendVersion: sendVersion, + lastEmailSentAt: new Date(), + emailStatus: "sent", + + agreementYn: vendor.contractRequirements.agreementYn || false, + ndaYn: vendor.contractRequirements.ndaYn || false, + projectGtcYn: vendor.contractRequirements.projectGtcYn || false, + generalGtcYn: vendor.contractRequirements.generalGtcYn || false, + + + }) + .returning(); + + + // 생성된 PDF 저장 및 DB 기록 + if (generatedPdfs && vendor.contractRequirements) { + const vendorPdfs = generatedPdfs.filter(pdf => + pdf.key.startsWith(`${vendor.vendorId}_`) + ); + + for (const pdfData of vendorPdfs) { + console.log(vendor.vendorId, pdfData.buffer.length) + // PDF 파일 저장 + const pdfBuffer = Buffer.from(pdfData.buffer); + const fileName = pdfData.fileName; + const filePath = path.join(contractsDir, fileName); + + await writeFile(filePath, pdfBuffer); + + const templateName = pdfData.key.split('_')[2]; + + const [template] = await db + .select() + .from(basicContractTemplates) + .where( + and( + ilike(basicContractTemplates.templateName, `%${templateName}%`), + eq(basicContractTemplates.status, "ACTIVE") + ) + ) + .limit(1); + + console.log("템플릿", templateName, template) + + // 기존 계약이 있는지 확인 + const [existingContract] = await tx + .select() + .from(basicContract) + .where( + and( + eq(basicContract.templateId, template.id), + eq(basicContract.vendorId, vendor.vendorId), + eq(basicContract.rfqCompanyId, newRfqDetail.id) + ) + ) + .limit(1); + + let contractRecord; + + if (existingContract) { + // 기존 계약이 있으면 업데이트 + [contractRecord] = await tx + .update(basicContract) + .set({ + requestedBy: Number(currentUser.id), + status: "PENDING", // 재발송 상태 + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, + deadline: addDays(new Date(), 10), + updatedAt: new Date(), + // version을 증가시키거나 이력 관리가 필요하면 추가 + }) + .where(eq(basicContract.id, existingContract.id)) + .returning(); + + console.log("기존 계약 업데이트:", contractRecord.id) + } else { + // 새 계약 생성 + [contractRecord] = await tx + .insert(basicContract) + .values({ + templateId: template.id, + vendorId: vendor.vendorId, + rfqCompanyId: newRfqDetail.id, + requestedBy: Number(currentUser.id), + status: "PENDING", + fileName: fileName, + filePath: `/contracts/generated/${fileName}`, + deadline: addDays(new Date(), 10), + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + console.log("새 계약 생성:", contractRecord.id) + } + + console.log(contractRecord.vendorId, contractRecord.filePath) + + savedContracts.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + templateName: templateName, + contractId: contractRecord.id, + fileName: fileName, + isUpdated: !!existingContract // 업데이트 여부 표시 + }); + } + } + + // 7.3 기존 응답 레코드 확인 + const existingResponses = await tx + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendor.vendorId) + ) + ); + + // 7.4 기존 응답이 있으면 isLatest=false로 업데이트 + if (existingResponses.length > 0) { + await tx + .update(rfqLastVendorResponses) + .set({ + isLatest: false + }) + .where( + and( + eq(rfqLastVendorResponses.vendorId, vendor.vendorId), + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + ) + ) + } + + // 7.5 새로운 응답 레코드 생성 + const newResponseVersion = existingResponses.length > 0 + ? Math.max(...existingResponses.map(r => r.responseVersion)) + 1 + : 1; + + const [vendorResponse] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: newRfqDetail.id, + vendorId: vendor.vendorId, + responseVersion: newResponseVersion, + isLatest: true, + status: "초대됨", + currency: rfqDetail.currency || "USD", + + // 감사 필드 + createdBy: currentUser.id, + updatedBy: currentUser.id, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + + // ========== TBE 세션 및 문서 검토 레코드 생성 시작 ========== + // 7.4 기존 활성 TBE 세션 확인 + const [existingActiveTbe] = await tx + .select() + .from(rfqLastTbeSessions) + .where( + and( + eq(rfqLastTbeSessions.rfqsLastId, rfqId), + eq(rfqLastTbeSessions.vendorId, vendor.vendorId), + sql`${rfqLastTbeSessions.status} IN ('준비중', '진행중', '검토중', '보류')` + ) + ); + + // 7.5 활성 TBE 세션이 없는 경우에만 새로 생성 + if (!existingActiveTbe) { + // TBE 세션 코드 생성 + const year = new Date().getFullYear(); + const [lastTbeSession] = await tx + .select({ sessionCode: rfqLastTbeSessions.sessionCode }) + .from(rfqLastTbeSessions) + .where(sql`${rfqLastTbeSessions.sessionCode} LIKE 'TBE-${year}-%'`) + .orderBy(sql`${rfqLastTbeSessions.sessionCode} DESC`) + .limit(1); + + let sessionNumber = 1; + if (lastTbeSession?.sessionCode) { + const lastNumber = parseInt(lastTbeSession.sessionCode.split('-')[2]); + sessionNumber = isNaN(lastNumber) ? 1 : lastNumber + 1; + } + + const sessionCode = `TBE-${year}-${String(sessionNumber).padStart(3, '0')}`; + + // TBE 세션 생성 + const [tbeSession] = await tx + .insert(rfqLastTbeSessions) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: newRfqDetail.id, + vendorId: vendor.vendorId, + sessionCode: sessionCode, + sessionTitle: `${rfqData.rfqCode} - ${vendor.vendorName} 기술검토`, + sessionType: "initial", + status: "준비중", + evaluationResult: null, + plannedStartDate: rfqData.dueDate ? addDays(new Date(rfqData.dueDate), 1) : addDays(new Date(), 14), + plannedEndDate: rfqData.dueDate ? addDays(new Date(rfqData.dueDate), 7) : addDays(new Date(), 21), + leadEvaluatorId: rfqData.picId, + createdBy: Number(currentUser.id), + updatedBy: Number(currentUser.id), + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + console.log(`TBE 세션 생성됨: ${sessionCode} for vendor ${vendor.vendorName}`); + + // ========== 설계 문서에 대한 검토 레코드 생성 (중요!) ========== + const documentReviewsCreated = []; + + for (const { attachment, revision } of designAttachments) { + const [documentReview] = await tx + .insert(rfqLastTbeDocumentReviews) + .values({ + tbeSessionId: tbeSession.id, + documentSource: "buyer", + buyerAttachmentId: attachment.id, + buyerAttachmentRevisionId: revision?.id || null, + vendorAttachmentId: null, // 구매자 문서이므로 null + documentType: attachment.attachmentType, + documentName: revision?.originalFileName || attachment.serialNo, + reviewStatus: "미검토", + technicalCompliance: null, + qualityAcceptable: null, + requiresRevision: false, + reviewComments: null, + revisionRequirements: null, + hasPdftronComments: false, + pdftronDocumentId: null, + pdftronAnnotationCount: 0, + reviewedBy: null, + reviewedAt: null, + additionalReviewers: null, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + + documentReviewsCreated.push({ + reviewId: documentReview.id, + attachmentId: attachment.id, + documentName: documentReview.documentName + }); + + console.log(`문서 검토 레코드 생성: ${documentReview.documentName}`); + } + + tbeSessionsCreated.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + sessionId: tbeSession.id, + sessionCode: tbeSession.sessionCode, + documentReviewsCount: documentReviewsCreated.length + }); + + console.log(`TBE 세션 ${sessionCode}: 총 ${documentReviewsCreated.length}개 문서 검토 레코드 생성`); + } else { + console.log(`TBE 세션이 이미 존재함: vendor ${vendor.vendorName}`); + } + // ========== TBE 세션 및 문서 검토 레코드 생성 끝 ========== + + } + + // 8. RFQ 상태 업데이트 + if (results.length > 0) { + await tx + .update(rfqsLast) + .set({ + status: "RFQ 발송", + rfqSendDate: new Date(), + sentBy: Number(currentUser.id), + updatedBy: Number(currentUser.id), + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, rfqId)); + } + + return { + success: true, + results, + errors, + savedContracts, + totalSent: results.length, + totalFailed: errors.length, + totalContracts: savedContracts.length + }; + }); + + return result; + + } catch (error) { + console.error("RFQ 발송 실패:", error); + throw new Error( + error instanceof Error + ? error.message + : "RFQ 발송 중 오류가 발생했습니다." + ); + } +} + +export async function updateRfqDueDate( + rfqId: number, + newDueDate: Date | string, + rfqCode: string, + rfqTitle: string +) { + try { + // ✅ 날짜 정규화 - 문자열을 Date 객체로 변환 + let normalizedDate: Date; + + if (typeof newDueDate === 'string') { + // ISO 문자열인 경우 (2024-01-15T14:30:00.000Z) + if (newDueDate.includes('T')) { + normalizedDate = new Date(newDueDate); + } + // YYYY-MM-DD HH:mm 형식인 경우 + else if (newDueDate.includes(' ') && newDueDate.includes(':')) { + normalizedDate = new Date(newDueDate); + } + // YYYY-MM-DD 형식인 경우 - 한국 시간 기준으로 설정 + else if (/^\d{4}-\d{2}-\d{2}$/.test(newDueDate)) { + normalizedDate = new Date(`${newDueDate}T00:00:00+09:00`); + } + else { + normalizedDate = new Date(newDueDate); + } + } else if (newDueDate instanceof Date) { + normalizedDate = newDueDate; + } else { + // Date 객체가 아닌 경우 처리 + normalizedDate = new Date(newDueDate as any); + } + + // 유효한 날짜인지 확인 + if (isNaN(normalizedDate.getTime())) { + return { + success: false, + message: "유효하지 않은 날짜 형식입니다.", + } + } + + // 1. RFQ 정보 조회 + const rfqData = await db + .select() + .from(rfqsLast) + .where(eq(rfqsLast.id, rfqId)) + .limit(1) + + if (!rfqData || rfqData.length === 0) { + return { + success: false, + message: "RFQ를 찾을 수 없습니다.", + } + } + + const rfq = rfqData[0] + const oldDueDate = rfq.dueDate + + // 2. Due Date 업데이트 - normalizedDate 사용 + await db + .update(rfqsLast) + .set({ + dueDate: normalizedDate, + updatedAt: new Date() + }) + .where(eq(rfqsLast.id, rfqId)) + + // 3. 프로젝트 정보 조회 (있는 경우) + let projectInfo = null + if (rfq.projectId) { + const projectData = await db + .select() + .from(projects) + .where(eq(projects.id, rfq.projectId)) + .limit(1) + + if (projectData && projectData.length > 0) { + projectInfo = projectData[0] + } + } + + // 4. PIC 정보 조회 + let picInfo = null + if (rfq.pic) { + const picData = await db + .select() + .from(users) + .where(eq(users.id, rfq.pic)) + .limit(1) + + if (picData && picData.length > 0) { + picInfo = picData[0] + } + } + + const picName = picInfo?.name || rfq.picName || "구매팀" + const picEmail = picInfo?.email || process.env.Email_From_Address || "procurement@company.com" + + // 5. RFQ Details에서 이메일 수신자 조회 + const rfqDetailsData = await db + .select({ + emailSentTo: rfqLastDetails.emailSentTo, + vendorId: rfqLastDetails.vendorsId, + }) + .from(rfqLastDetails) + .where(eq(rfqLastDetails.rfqsLastId, rfqId)) + + if (rfqDetailsData.length === 0) { + // 페이지 재검증 + revalidatePath(`/[lng]/evcp/rfq-last/${rfqId}`, 'layout') + + return { + success: true, + message: "마감일이 수정되었습니다. (발송된 이메일이 없음)", + } + } + + // 6. 각 vendor별로 이메일 발송 + const emailPromises = [] + + for (const detail of rfqDetailsData) { + if (!detail.emailSentTo) continue + + // vendor 정보 조회 + let vendorInfo = null + if (detail.vendorId) { + const vendorData = await db + .select() + .from(vendors) + .where(eq(vendors.id, detail.vendorId)) + .limit(1) + + if (vendorData && vendorData.length > 0) { + vendorInfo = vendorData[0] + } + } + + // 이메일 언어 결정 (vendor의 country가 KR이면 한국어, 아니면 영어) + const isKorean = vendorInfo?.country === 'KR' + const language = isKorean ? 'ko' : 'en' + const locale = isKorean ? ko : enUS + + const emailSubject = isKorean + ? `[마감일 변경] ${rfqCode} ${rfqTitle || ''}`.trim() + : `[Due Date Changed] ${rfqCode} ${rfqTitle || ''}`.trim() + + // ✅ 날짜 포맷팅 - 시간 포함하도록 수정 + const oldDateFormatted = (() => { + try { + if (!oldDueDate) { + return isKorean ? "미설정" : "Not set"; + } + const date = new Date(oldDueDate); + // 시간까지 포함한 포맷 + return format(date, "PPP HH:mm", { locale }); + } catch { + return isKorean ? "미설정" : "Not set"; + } + })(); + + const newDateFormatted = (() => { + try { + // normalizedDate 사용 + return format(normalizedDate, "PPP HH:mm", { locale }); + } catch (error) { + console.error("Date formatting error:", error); + return normalizedDate.toISOString(); + } + })(); + + // 이메일 발송 - null/undefined 값 처리 + const emailContext: Record<string, unknown> = { + language: language ?? (isKorean ? "ko" : "en"), + vendorName: vendorInfo?.vendorName ?? "", + rfqCode: rfqCode ?? "", + rfqTitle: rfqTitle ?? "", + rfqType: rfq.rfqType ?? "", + projectCode: projectInfo?.code ?? "", + projectName: projectInfo?.name ?? "", + packageNo: rfq.packageNo ?? "", + packageName: rfq.packageName ?? "", + itemCode: rfq.itemCode ?? "", + itemName: rfq.itemName ?? "", + oldDueDate: oldDateFormatted, + newDueDate: newDateFormatted, + picName: picName ?? "구매팀", + picEmail: picEmail ?? (process.env.Email_From_Address ?? "procurement@company.com"), + engPicName: rfq.EngPicName ?? "", + portalUrl: + (process.env.NEXT_PUBLIC_APP_URL + ? `${process.env.NEXT_PUBLIC_APP_URL}/partners/rfq-last/${rfqId}` + : `https://partners.sevcp.com/partners/rfq-last/${rfqId}`), + }; + + const validContext = Object.fromEntries( + Object.entries(emailContext ?? {}).filter(([, value]) => value !== null && value !== undefined) + ); + + let toEmails: string[] = []; + let ccEmails: string[] = []; + + try { + const emailData = typeof detail.emailSentTo === 'string' + ? JSON.parse(detail.emailSentTo) + : detail.emailSentTo; + + if (emailData.to) { + toEmails = Array.isArray(emailData.to) + ? emailData.to.filter(Boolean) + : [emailData.to].filter(Boolean); + } + + if (emailData.cc) { + ccEmails = Array.isArray(emailData.cc) + ? emailData.cc.filter(Boolean) + : [emailData.cc].filter(Boolean); + } + } catch (error) { + console.warn("Failed to parse emailSentTo as JSON, trying comma-separated:", error); + toEmails = (detail.emailSentTo ?? "") + .split(",") + .map((e) => e.trim()) + .filter(Boolean); + } + + console.log("Parsed emails - To:", toEmails, "CC:", ccEmails); + + if (toEmails.length === 0) { + continue; + } + + emailPromises.push( + sendEmail({ + from: isDevelopment + ? (process.env.Email_From_Address ?? "no-reply@company.com") + : `"${picName}" <${picEmail}>`, + to: toEmails.join(", "), + cc: ccEmails.length > 0 ? ccEmails.join(", ") : undefined, + subject: emailSubject, + template: "rfq-due-date-change", + context: validContext, + }) + ); + } + + // 모든 이메일 발송 + if (emailPromises.length > 0) { + await Promise.allSettled(emailPromises) + } + + try { + await revalidatePath(`/[lng]/evcp/rfq-last/${rfqId}`, "layout"); + } catch (e) { + console.warn("revalidatePath failed:", e); + } + + // ✅ 성공 메시지도 시간 포함하도록 수정 + return { + success: true, + message: `마감일이 ${format(normalizedDate, "yyyy년 MM월 dd일 HH시 mm분", { locale: ko })}로 수정되었으며, 관련 업체에 이메일이 발송되었습니다.`, + } + } catch (error) { + console.error("Error updating due date:", error) + return { + success: false, + message: "마감일 수정 중 오류가 발생했습니다.", + } + } +} + + +export async function deleteRfqVendor({ + rfqId, + detailId, + vendorId, +}: { + rfqId: number; + detailId: number; + vendorId: number; +}): Promise<{ + success: boolean; + message?: string; +}> { + try { + const deleteResult = await db + .delete(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.id, detailId), + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ) + .returning({ id: rfqLastDetails.id }); + + if (deleteResult.length === 0) { + throw new Error("삭제할 벤더를 찾을 수 없습니다."); + } + + // 캐시 무효화 + revalidatePath(`/partners/rfq-last/${rfqId}`); + + return { + success: true, + message: "벤더가 성공적으로 삭제되었습니다.", + }; + } catch (error) { + console.error("벤더 삭제 오류:", error); + + return { + success: false, + message: error instanceof Error ? error.message : "벤더 삭제 중 오류가 발생했습니다.", + }; + } +} + +export async function updateVendorContractRequirements({ + rfqId, + detailId, + contractRequirements, +}: UpdateVendorContractRequirementsParams): Promise<UpdateResult> { + try { + // gtcType에 따라 generalGtcYn과 projectGtcYn 설정 + const generalGtcYn = contractRequirements.gtcType === "general"; + const projectGtcYn = contractRequirements.gtcType === "project"; + + // 데이터베이스 업데이트 + const result = await db + .update(rfqLastDetails) + .set({ + agreementYn: contractRequirements.agreementYn, + ndaYn: contractRequirements.ndaYn, + gtcType: contractRequirements.gtcType, + generalGtcYn, + projectGtcYn, + updatedAt: new Date(), + // updatedBy는 세션에서 가져와야 하는 경우 추가 + // updatedBy: getCurrentUserId(), + }) + .where(eq(rfqLastDetails.id, detailId)) + .returning(); + + // 결과 검증 + if (!result || result.length === 0) { + return { + success: false, + error: "업체 정보를 찾을 수 없습니다.", + }; + } + + // // 캐시 재검증 (필요한 경우) + // revalidatePath(`/rfq/${rfqId}`); + // revalidatePath(`/rfq/${rfqId}/vendors`); + + return { + success: true, + data: result[0], + }; + } catch (error) { + console.error("Error updating vendor contract requirements:", error); + + return { + success: false, + error: error instanceof Error ? error.message : "업데이트 중 오류가 발생했습니다.", + }; + } +} + +// 헬퍼 함수 +function getTemplateNameByType( + contractType: string, + requirements: any +): string { + switch (contractType) { + case "NDA": return "비밀"; + case "General_GTC": return "General GTC"; + case "Project_GTC": return requirements.projectCode || "Project GTC"; + case "기술자료": return "기술"; + default: return contractType; + } }
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-attachments-dialog.tsx b/lib/rfq-last/table/rfq-attachments-dialog.tsx index 253daaa2..05747c6c 100644 --- a/lib/rfq-last/table/rfq-attachments-dialog.tsx +++ b/lib/rfq-last/table/rfq-attachments-dialog.tsx @@ -197,7 +197,7 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment <TableRow> <TableHead className="w-[120px]">타입</TableHead> <TableHead>파일명</TableHead> - <TableHead>설명</TableHead> + {/* <TableHead>설명</TableHead> */} <TableHead className="w-[90px]">리비전</TableHead> <TableHead className="w-[100px]">크기</TableHead> <TableHead className="w-[120px]">생성자</TableHead> @@ -237,15 +237,10 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment <span className="text-sm font-medium truncate" title={attachment.originalFileName}> {attachment.originalFileName} </span> - {attachment.fileName !== attachment.originalFileName && ( - <span className="text-xs text-muted-foreground truncate" title={attachment.fileName}> - {attachment.fileName} - </span> - )} </div> </div> </TableCell> - <TableCell> + {/* <TableCell> <span className="text-sm" title={attachment.description || ""}> {attachment.description || "-"} </span> @@ -254,7 +249,7 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment {attachment.revisionComment} </div> )} - </TableCell> + </TableCell> */} <TableCell> <Badge variant="secondary" className="font-mono text-xs"> {attachment.currentRevision} @@ -300,7 +295,7 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment </Button> {/* 스마트 액션 버튼 (메인 액션) */} - <Button + {/* <Button variant="outline" size="sm" onClick={() => handleSmartAction(attachment)} @@ -315,7 +310,7 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment <Download className="h-4 w-4 mr-1" /> )} {fileInfo.canPreview ? "보기" : "다운로드"} - </Button> + </Button> */} </div> </TableCell> </TableRow> @@ -339,9 +334,6 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment )}` } </span> - <span> - {fileInfo.icon} 미리보기 가능 | 📥 다운로드 - </span> </div> </div> )} diff --git a/lib/rfq-last/table/rfq-items-dialog.tsx b/lib/rfq-last/table/rfq-items-dialog.tsx index 5d7e0747..daa692e9 100644 --- a/lib/rfq-last/table/rfq-items-dialog.tsx +++ b/lib/rfq-last/table/rfq-items-dialog.tsx @@ -143,8 +143,8 @@ export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps {/* 통계 정보 */} {statistics && !isLoading && ( <> - <div className="grid grid-cols-5 gap-3 py-3"> - <div className="text-center"> +<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 py-3"> +<div className="text-center"> <div className="text-2xl font-bold text-primary">{statistics.total}</div> <div className="text-xs text-muted-foreground">전체 품목</div> </div> diff --git a/lib/rfq-last/vendor-response/editor/attachments-upload.tsx b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx new file mode 100644 index 00000000..a2967767 --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/attachments-upload.tsx @@ -0,0 +1,466 @@ +"use client" + +import { useState, useRef } from "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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Upload, + FileText, + File, + Trash2, + Download, + AlertCircle, + Paperclip, + FileCheck, + Calculator, + Wrench +} from "lucide-react" +import { formatBytes } from "@/lib/utils" +import { cn } from "@/lib/utils" + +interface FileWithType extends File { + attachmentType?: "구매" | "설계" + description?: string +} + +interface AttachmentsUploadProps { + attachments: FileWithType[] + onAttachmentsChange: (files: FileWithType[]) => void + existingAttachments?: any[] +} + +const acceptedFileTypes = { + documents: ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx", + images: ".jpg,.jpeg,.png,.gif,.bmp", + compressed: ".zip,.rar,.7z", + all: ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.gif,.bmp,.zip,.rar,.7z" +} + +export default function AttachmentsUpload({ + attachments, + onAttachmentsChange, + existingAttachments = [] +}: 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 validateFile = (file: File): string | null => { + const maxSize = 1024 * 1024 * 1024 // 10MB + const allowedExtensions = acceptedFileTypes.all.split(',') + const fileExtension = `.${file.name.split('.').pop()?.toLowerCase()}` + + if (file.size > maxSize) { + return `${file.name}: 파일 크기가 1GB를 초과합니다` + } + + if (!allowedExtensions.includes(fileExtension)) { + return `${file.name}: 허용되지 않은 파일 형식입니다` + } + + return null + } + + // 파일 추가 + const handleFileAdd = (files: FileList | null, type: "구매" | "설계") => { + if (!files) return + + const newFiles: FileWithType[] = [] + const errors: string[] = [] + + Array.from(files).forEach(file => { + const error = validateFile(file) + if (error) { + errors.push(error) + } else { + const fileWithType = Object.assign(file, { + attachmentType: type, + description: "" + }) + newFiles.push(fileWithType) + } + }) + + if (errors.length > 0) { + setUploadErrors(errors) + setTimeout(() => setUploadErrors([]), 5000) + } + + if (newFiles.length > 0) { + onAttachmentsChange([...attachments, ...newFiles]) + } + } + + // 구매 드래그 앤 드롭 핸들러 + const handlePurchaseDrag = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.type === "dragenter" || e.type === "dragover") { + setPurchaseDragActive(true) + } else if (e.type === "dragleave") { + setPurchaseDragActive(false) + } + } + + const handlePurchaseDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setPurchaseDragActive(false) + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFileAdd(e.dataTransfer.files, "구매") + } + } + + // 설계 드래그 앤 드롭 핸들러 + const handleDesignDrag = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.type === "dragenter" || e.type === "dragover") { + setDesignDragActive(true) + } else if (e.type === "dragleave") { + setDesignDragActive(false) + } + } + + const handleDesignDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDesignDragActive(false) + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + handleFileAdd(e.dataTransfer.files, "설계") + } + } + + // 파일 삭제 + const handleFileRemove = (index: number) => { + const newFiles = attachments.filter((_, i) => i !== index) + onAttachmentsChange(newFiles) + } + + // 파일 타입 변경 + const handleTypeChange = (index: number, newType: "구매" | "설계") => { + const newFiles = [...attachments] + newFiles[index].attachmentType = newType + onAttachmentsChange(newFiles) + } + + // 파일 아이콘 가져오기 + const getFileIcon = (fileName: string) => { + const extension = fileName.split('.').pop()?.toLowerCase() + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp'] + + if (imageExtensions.includes(extension || '')) { + return <File className="h-4 w-4 text-blue-500" /> + } + return <FileText className="h-4 w-4 text-gray-500" /> + } + + // 구매/설계 문서 개수 계산 + const purchaseCount = attachments.filter(f => f.attachmentType === "구매").length + + existingAttachments.filter(f => f.attachmentType === "구매").length + const designCount = attachments.filter(f => f.attachmentType === "설계").length + + existingAttachments.filter(f => f.attachmentType === "설계").length + + return ( + <div className="space-y-4"> + {/* 필수 파일 안내 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>문서 분류:</strong> 구매 문서(견적서, 상업조건 등)와 설계 문서(기술문서, 성적서, 인증서 등)를 구분하여 업로드하세요. + <br /> + <strong>허용 파일:</strong> PDF, Word, Excel, PowerPoint, 이미지 파일, 압축 파일(ZIP, RAR, 7Z) (최대 1GB) + </AlertDescription> + </Alert> + + {/* 업로드 오류 표시 */} + {uploadErrors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <ul className="list-disc list-inside"> + {uploadErrors.map((error, index) => ( + <li key={index}>{error}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {/* 두 개의 드래그존 */} + <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> + {/* 구매 문서 업로드 영역 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Calculator className="h-5 w-5" /> + 구매 문서 + </CardTitle> + <CardDescription> + 견적서, 금액, 상업조건 관련 문서 + </CardDescription> + </CardHeader> + <CardContent> + <div + className={cn( + "border-2 border-dashed rounded-lg p-6 text-center transition-colors", + purchaseDragActive ? "border-blue-500 bg-blue-50" : "border-gray-300", + "hover:border-blue-400 hover:bg-blue-50/50" + )} + onDragEnter={handlePurchaseDrag} + onDragLeave={handlePurchaseDrag} + onDragOver={handlePurchaseDrag} + onDrop={handlePurchaseDrop} + > + <Calculator className="mx-auto h-10 w-10 text-blue-500 mb-3" /> + <p className="text-sm text-gray-600 mb-2"> + 구매 문서를 드래그하여 업로드 + </p> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => purchaseInputRef.current?.click()} + className="border-blue-500 text-blue-600 hover:bg-blue-50" + > + <Paperclip className="h-4 w-4 mr-2" /> + 구매 문서 선택 + </Button> + <input + ref={purchaseInputRef} + type="file" + multiple + accept={acceptedFileTypes.all} + onChange={(e) => handleFileAdd(e.target.files, "구매")} + className="hidden" + /> + {purchaseCount > 0 && ( + <div className="mt-2"> + <Badge variant="secondary">{purchaseCount}개 업로드됨</Badge> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 설계 문서 업로드 영역 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Wrench className="h-5 w-5" /> + 설계 문서 + </CardTitle> + <CardDescription> + 기술문서, 성적서, 인증서, 도면 등 + </CardDescription> + </CardHeader> + <CardContent> + <div + className={cn( + "border-2 border-dashed rounded-lg p-6 text-center transition-colors", + designDragActive ? "border-green-500 bg-green-50" : "border-gray-300", + "hover:border-green-400 hover:bg-green-50/50" + )} + onDragEnter={handleDesignDrag} + onDragLeave={handleDesignDrag} + onDragOver={handleDesignDrag} + onDrop={handleDesignDrop} + > + <Wrench className="mx-auto h-10 w-10 text-green-500 mb-3" /> + <p className="text-sm text-gray-600 mb-2"> + 설계 문서를 드래그하여 업로드 + </p> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => designInputRef.current?.click()} + className="border-green-500 text-green-600 hover:bg-green-50" + > + <Paperclip className="h-4 w-4 mr-2" /> + 설계 문서 선택 + </Button> + <input + ref={designInputRef} + type="file" + multiple + accept={acceptedFileTypes.all} + onChange={(e) => handleFileAdd(e.target.files, "설계")} + className="hidden" + /> + {designCount > 0 && ( + <div className="mt-2"> + <Badge variant="secondary">{designCount}개 업로드됨</Badge> + </div> + )} + {/* <p className="text-xs text-gray-500 mt-2"> + 최대 1GB, 여러 파일 선택 가능 + </p> */} + </div> + </CardContent> + </Card> + </div> + + {/* 첨부파일 목록 */} + {(attachments.length > 0 || existingAttachments.length > 0) && ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle>첨부파일 목록</CardTitle> + <div className="flex items-center gap-2"> + <Badge variant="outline" className="gap-1"> + <Calculator className="h-3 w-3" /> + 구매 {purchaseCount} + </Badge> + <Badge variant="outline" className="gap-1"> + <Wrench className="h-3 w-3" /> + 설계 {designCount} + </Badge> + <Badge variant="secondary"> + 총 {attachments.length + existingAttachments.length}개 + </Badge> + </div> + </div> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">유형</TableHead> + <TableHead>파일명</TableHead> + <TableHead className="w-[100px]">크기</TableHead> + <TableHead className="w-[120px]">문서 구분</TableHead> + <TableHead className="w-[100px]">상태</TableHead> + <TableHead className="w-[80px]">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {/* 기존 첨부파일 */} + {existingAttachments.map((file, index) => ( + <TableRow key={`existing-${index}`}> + <TableCell> + {getFileIcon(file.originalFileName)} + </TableCell> + <TableCell> + <div> + <p className="font-medium">{file.originalFileName}</p> + {file.description && ( + <p className="text-xs text-muted-foreground"> + {file.description} + </p> + )} + </div> + </TableCell> + <TableCell className="text-sm text-muted-foreground"> + {formatBytes(file.fileSize || 0)} + </TableCell> + <TableCell> + <Badge + variant={file.attachmentType === "구매" ? "default" : "secondary"} + className="gap-1" + > + {file.attachmentType === "구매" ? + <Calculator className="h-3 w-3" /> : + <Wrench className="h-3 w-3" /> + } + {file.attachmentType} + </Badge> + </TableCell> + <TableCell> + <Badge variant="secondary"> + <FileCheck className="h-3 w-3 mr-1" /> + 기존 + </Badge> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => window.open(file.filePath, '_blank')} + > + <Download className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ))} + + {/* 새로 추가된 파일 */} + {attachments.map((file, index) => ( + <TableRow key={`new-${index}`}> + <TableCell> + {getFileIcon(file.name)} + </TableCell> + <TableCell> + <div> + <p className="font-medium">{file.name}</p> + </div> + </TableCell> + <TableCell className="text-sm text-muted-foreground"> + {formatBytes(file.size)} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Button + type="button" + variant={file.attachmentType === "구매" ? "default" : "ghost"} + size="sm" + className="h-7 px-2 text-xs" + onClick={() => handleTypeChange(index, "구매")} + > + <Calculator className="h-3 w-3 mr-1" /> + 구매 + </Button> + <Button + type="button" + variant={file.attachmentType === "설계" ? "default" : "ghost"} + size="sm" + className="h-7 px-2 text-xs" + onClick={() => handleTypeChange(index, "설계")} + > + <Wrench className="h-3 w-3 mr-1" /> + 설계 + </Button> + </div> + </TableCell> + <TableCell> + <Badge variant="default"> + <Upload className="h-3 w-3 mr-1" /> + 신규 + </Badge> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleFileRemove(index)} + > + <Trash2 className="h-4 w-4 text-red-500" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + )} + </div> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx new file mode 100644 index 00000000..143d08f3 --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx @@ -0,0 +1,713 @@ +"use client" + +import * as React from "react" +import { useFormContext } from "react-hook-form" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { InfoIcon, ChevronsUpDown, Check, CalendarIcon, Loader2 } from "lucide-react" +import { format } from "date-fns" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + getIncotermsForSelection, + getPaymentTermsForSelection, + getPlaceOfShippingForSelection, + getPlaceOfDestinationForSelection +} from "@/lib/procurement-select/service" +import { toast } from "sonner" + +interface CommercialTermsFormProps { + rfqDetail: any + rfq: any +} + +interface SelectOption { + id: number + code: string + description: string +} + +export default function CommercialTermsForm({ rfqDetail, rfq }: CommercialTermsFormProps) { + const { register, setValue, watch, formState: { errors } } = useFormContext() + + console.log(rfqDetail,"rfqDetail") + + // RFQ 코드가 F로 시작하는지 확인 + const isFrameContract = rfq?.rfqCode?.startsWith("F") + + // 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) + const [shippingOpen, setShippingOpen] = React.useState(false) + const [destinationOpen, setDestinationOpen] = React.useState(false) + + const vendorCurrency = watch("vendorCurrency") + const vendorPaymentTermsCode = watch("vendorPaymentTermsCode") + const vendorIncotermsCode = watch("vendorIncotermsCode") + const vendorDeliveryDate = watch("vendorDeliveryDate") + const vendorContractDuration = watch("vendorContractDuration") + const vendorFirstYn = watch("vendorFirstYn") + const vendorSparepartYn = watch("vendorSparepartYn") + const vendorMaterialPriceRelatedYn = watch("vendorMaterialPriceRelatedYn") + + // 구매자 제시 조건과 다른지 확인 + const isDifferentCurrency = vendorCurrency !== rfqDetail.currency + const isDifferentPaymentTerms = vendorPaymentTermsCode !== rfqDetail.paymentTermsCode + const isDifferentIncoterms = vendorIncotermsCode !== rfqDetail.incotermsCode + const isDifferentDeliveryDate = !isFrameContract && vendorDeliveryDate?.toISOString() !== rfqDetail.deliveryDate + const isDifferentContractDuration = isFrameContract && vendorContractDuration !== rfqDetail.contractDuration + + // 데이터 로드 함수들 + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true) + try { + const data = await getIncotermsForSelection() + setIncoterms(data) + } catch (error) { + console.error("Failed to load incoterms:", error) + toast.error("Incoterms 목록을 불러오는데 실패했습니다.") + } finally { + setIncotermsLoading(false) + } + }, []) + + const loadPaymentTerms = React.useCallback(async () => { + setPaymentTermsLoading(true) + try { + const data = await getPaymentTermsForSelection() + setPaymentTerms(data) + } catch (error) { + console.error("Failed to load payment terms:", error) + toast.error("결제조건 목록을 불러오는데 실패했습니다.") + } finally { + setPaymentTermsLoading(false) + } + }, []) + + const loadShippingPlaces = React.useCallback(async () => { + setShippingLoading(true) + try { + const data = await getPlaceOfShippingForSelection() + setShippingPlaces(data) + } catch (error) { + console.error("Failed to load shipping places:", error) + toast.error("선적지 목록을 불러오는데 실패했습니다.") + } finally { + setShippingLoading(false) + } + }, []) + + const loadDestinationPlaces = React.useCallback(async () => { + setDestinationLoading(true) + try { + const data = await getPlaceOfDestinationForSelection() + setDestinationPlaces(data) + } catch (error) { + console.error("Failed to load destination places:", error) + toast.error("도착지 목록을 불러오는데 실패했습니다.") + } finally { + setDestinationLoading(false) + } + }, []) + + // 컴포넌트 마운트 시 데이터 로드 + React.useEffect(() => { + loadIncoterms() + loadPaymentTerms() + loadShippingPlaces() + loadDestinationPlaces() + }, [loadIncoterms, loadPaymentTerms, loadShippingPlaces, loadDestinationPlaces]) + + // 선택된 옵션 찾기 + const selectedIncoterm = incoterms.find(i => i.code === vendorIncotermsCode) + const selectedPaymentTerm = paymentTerms.find(p => p.code === vendorPaymentTermsCode) + const selectedShipping = shippingPlaces.find(s => s.code === watch("vendorPlaceOfShipping")) + const selectedDestination = destinationPlaces.find(d => d.code === watch("vendorPlaceOfDestination")) + + return ( + <div className="space-y-6"> + {/* 기본 상업 조건 */} + <Card> + <CardHeader> + <CardTitle>기본 상업 조건</CardTitle> + <CardDescription> + 구매자가 제시한 조건과 다른 경우, 변경 사유를 반드시 입력해주세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {/* 통화 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 통화</Label> + <Input value={rfqDetail.currency || '-'} disabled /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorCurrency">벤더 제안 통화</Label> + <Select + value={vendorCurrency} + onValueChange={(value) => setValue("vendorCurrency", value)} + > + <SelectTrigger> + <SelectValue placeholder="통화 선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="USD">USD</SelectItem> + <SelectItem value="KRW">KRW</SelectItem> + <SelectItem value="EUR">EUR</SelectItem> + <SelectItem value="JPY">JPY</SelectItem> + <SelectItem value="CNY">CNY</SelectItem> + </SelectContent> + </Select> + </div> + </div> + {isDifferentCurrency && ( + <div className="space-y-2"> + <Label htmlFor="currencyReason">통화 변경 사유 *</Label> + <Textarea + id="currencyReason" + {...register("currencyReason")} + placeholder="통화 변경 사유를 입력하세요" + className="min-h-[80px]" + /> + </div> + )} + + {/* 지불 조건 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 지불조건</Label> + <Input value={rfqDetail.paymentTermsDescription || rfqDetail.paymentTermsCode || '-'} disabled /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorPaymentTermsCode">벤더 제안 지불조건</Label> + <Popover open={paymentTermsOpen} onOpenChange={setPaymentTermsOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={paymentTermsOpen} + className="w-full justify-between" + disabled={paymentTermsLoading} + > + {selectedPaymentTerm ? ( + <span className="truncate"> + {selectedPaymentTerm.code} - {selectedPaymentTerm.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {paymentTermsLoading ? "로딩 중..." : "지불조건 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="코드 또는 설명으로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {paymentTerms.map((term) => ( + <CommandItem + key={term.id} + value={`${term.code} ${term.description}`} + onSelect={() => { + setValue("vendorPaymentTermsCode", term.code) + setPaymentTermsOpen(false) + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{term.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{term.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + term.code === vendorPaymentTermsCode ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + </div> + {isDifferentPaymentTerms && ( + <div className="space-y-2"> + <Label htmlFor="paymentTermsReason">지불조건 변경 사유 *</Label> + <Textarea + id="paymentTermsReason" + {...register("paymentTermsReason")} + placeholder="지불조건 변경 사유를 입력하세요" + className="min-h-[80px]" + /> + </div> + )} + + {/* 인코텀즈 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 인코텀즈</Label> + <Input value={`${rfqDetail.incotermsCode || ''} ${rfqDetail.incotermsDetail || ''}`.trim() || '-'} disabled /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorIncotermsCode">벤더 제안 인코텀즈</Label> + <Popover open={incotermsOpen} onOpenChange={setIncotermsOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={incotermsOpen} + className="w-full justify-between" + disabled={incotermsLoading} + > + {selectedIncoterm ? ( + <span className="truncate"> + {selectedIncoterm.code} - {selectedIncoterm.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {incotermsLoading ? "로딩 중..." : "인코텀즈 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="코드 또는 설명으로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {incoterms.map((incoterm) => ( + <CommandItem + key={incoterm.id} + value={`${incoterm.code} ${incoterm.description}`} + onSelect={() => { + setValue("vendorIncotermsCode", incoterm.code) + setIncotermsOpen(false) + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{incoterm.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{incoterm.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + incoterm.code === vendorIncotermsCode ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + </div> + {isDifferentIncoterms && ( + <div className="space-y-2"> + <Label htmlFor="incotermsReason">인코텀즈 변경 사유 *</Label> + <Textarea + id="incotermsReason" + {...register("incotermsReason")} + placeholder="인코텀즈 변경 사유를 입력하세요" + className="min-h-[80px]" + /> + </div> + )} + + {/* 납기일 또는 계약 기간 */} + {isFrameContract ? ( + // 계약 기간 (F로 시작하는 경우) + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 계약기간</Label> + <Input value={rfqDetail.contractDuration || '-'} disabled /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorContractDuration">벤더 제안 계약기간</Label> + <Input + id="vendorContractDuration" + {...register("vendorContractDuration")} + placeholder="예: 12개월" + /> + </div> + </div> + ) : ( + // 납기일 (일반적인 경우) + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label>구매자 제시 납기일</Label> + <Input + value={rfqDetail.deliveryDate ? format(new Date(rfqDetail.deliveryDate), "yyyy-MM-dd") : '-'} + disabled + /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorDeliveryDate">벤더 제안 납기일</Label> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal", + !vendorDeliveryDate && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {vendorDeliveryDate ? format(vendorDeliveryDate, "yyyy-MM-dd") : "날짜 선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0"> + <Calendar + mode="single" + selected={vendorDeliveryDate} + onSelect={(date) => setValue("vendorDeliveryDate", date)} + initialFocus + /> + </PopoverContent> + </Popover> + </div> + </div> + )} + + {/* 납기일/계약기간 변경 사유 (공통) */} + {(isDifferentDeliveryDate || isDifferentContractDuration) && ( + <div className="space-y-2"> + <Label htmlFor="deliveryDateReason"> + {isFrameContract ? "계약기간 변경 사유 *" : "납기일 변경 사유 *"} + </Label> + <Textarea + id="deliveryDateReason" + {...register("deliveryDateReason")} + placeholder={isFrameContract ? "계약기간 변경 사유를 입력하세요" : "납기일 변경 사유를 입력하세요"} + className="min-h-[80px]" + /> + </div> + )} + + {/* 기타 조건들 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="vendorTaxCode">세금 코드</Label> + <Input + id="vendorTaxCode" + {...register("vendorTaxCode")} + placeholder="세금 코드 입력" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorPlaceOfShipping">선적지</Label> + <Popover open={shippingOpen} onOpenChange={setShippingOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={shippingOpen} + className="w-full justify-between" + disabled={shippingLoading} + > + {selectedShipping ? ( + <span className="truncate"> + {selectedShipping.code} - {selectedShipping.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {shippingLoading ? "로딩 중..." : "선적지 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="선적지 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {shippingPlaces.map((place) => ( + <CommandItem + key={place.id} + value={`${place.code} ${place.description}`} + onSelect={() => { + setValue("vendorPlaceOfShipping", place.code) + setShippingOpen(false) + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{place.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{place.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + place.code === watch("vendorPlaceOfShipping") ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorPlaceOfDestination">도착지</Label> + <Popover open={destinationOpen} onOpenChange={setDestinationOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={destinationOpen} + className="w-full justify-between" + disabled={destinationLoading} + > + {selectedDestination ? ( + <span className="truncate"> + {selectedDestination.code} - {selectedDestination.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {destinationLoading ? "로딩 중..." : "도착지 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="도착지 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {destinationPlaces.map((place) => ( + <CommandItem + key={place.id} + value={`${place.code} ${place.description}`} + onSelect={() => { + setValue("vendorPlaceOfDestination", place.code) + setDestinationOpen(false) + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{place.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{place.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + place.code === watch("vendorPlaceOfDestination") ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + </div> + </CardContent> + </Card> + + {/* 특수 조건 */} + <Card> + <CardHeader> + <CardTitle>특수 조건</CardTitle> + <CardDescription> + 구매자가 요청한 특수 조건에 대한 응답을 입력하세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + {/* 초도품 관리 */} + {rfqDetail.firstYn && ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Label>초도품 관리 요청</Label> + <Badge variant="secondary">요청됨</Badge> + </div> + {rfqDetail.firstDescription && ( + <Alert> + <InfoIcon className="h-4 w-4" /> + <AlertDescription>{rfqDetail.firstDescription}</AlertDescription> + </Alert> + )} + <div className="space-y-2"> + <Label>초도품 관리 수용 여부</Label> + <RadioGroup + value={watch("vendorFirstAcceptance") || ""} + onValueChange={(value) => setValue("vendorFirstAcceptance", value)} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="수용" id="first-accept" /> + <Label htmlFor="first-accept">수용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="부분수용" id="first-partial" /> + <Label htmlFor="first-partial">부분수용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="거부" id="first-reject" /> + <Label htmlFor="first-reject">거부</Label> + </div> + </RadioGroup> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorFirstDescription">초도품 관리 응답 상세</Label> + <Textarea + id="vendorFirstDescription" + {...register("vendorFirstDescription")} + placeholder="초도품 관리에 대한 상세 응답을 입력하세요" + className="min-h-[100px]" + /> + </div> + </div> + )} + + {/* Spare Part */} + {rfqDetail.sparepartYn && ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Label>Spare Part 요청</Label> + <Badge variant="secondary">요청됨</Badge> + </div> + {rfqDetail.sparepartDescription && ( + <Alert> + <InfoIcon className="h-4 w-4" /> + <AlertDescription>{rfqDetail.sparepartDescription}</AlertDescription> + </Alert> + )} + <div className="space-y-2"> + <Label>Spare Part 수용 여부</Label> + <RadioGroup + value={watch("vendorSparepartAcceptance") || ""} + onValueChange={(value) => setValue("vendorSparepartAcceptance", value)} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="수용" id="spare-accept" /> + <Label htmlFor="spare-accept">수용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="부분수용" id="spare-partial" /> + <Label htmlFor="spare-partial">부분수용</Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="거부" id="spare-reject" /> + <Label htmlFor="spare-reject">거부</Label> + </div> + </RadioGroup> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorSparepartDescription">Spare Part 응답 상세</Label> + <Textarea + id="vendorSparepartDescription" + {...register("vendorSparepartDescription")} + placeholder="Spare Part에 대한 상세 응답을 입력하세요" + className="min-h-[100px]" + /> + </div> + </div> + )} + + {/* 연동제 적용 */} + {rfqDetail.materialPriceRelatedYn && ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Label>연동제 적용 요청</Label> + <Badge variant="secondary">요청됨</Badge> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="vendorMaterialPriceRelatedYn" + checked={vendorMaterialPriceRelatedYn} + onCheckedChange={(checked) => setValue("vendorMaterialPriceRelatedYn", checked)} + /> + <Label htmlFor="vendorMaterialPriceRelatedYn">연동제 적용 동의</Label> + </div> + <div className="space-y-2"> + <Label htmlFor="vendorMaterialPriceRelatedReason">연동제 관련 의견</Label> + <Textarea + id="vendorMaterialPriceRelatedReason" + {...register("vendorMaterialPriceRelatedReason")} + placeholder="연동제 적용에 대한 의견을 입력하세요" + className="min-h-[100px]" + /> + </div> + </div> + )} + </CardContent> + </Card> + + {/* 추가 의견 */} + <Card> + <CardHeader> + <CardTitle>추가 의견</CardTitle> + <CardDescription> + 견적서에 대한 추가 의견을 입력하세요. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* <div className="space-y-2"> + <Label htmlFor="technicalProposal">기술 제안서</Label> + <Textarea + id="technicalProposal" + {...register("technicalProposal")} + placeholder="기술적 제안사항을 입력하세요" + className="min-h-[120px]" + /> + </div> */} + <div className="space-y-2"> + {/* <Label htmlFor="generalRemark">일반 비고</Label> */} + <Textarea + id="generalRemark" + {...register("generalRemark")} + placeholder="추가 비고사항을 입력하세요" + className="min-h-[120px]" + /> + </div> + </CardContent> + </Card> + </div> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx new file mode 100644 index 00000000..08928b4d --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx @@ -0,0 +1,449 @@ +"use client" + +import { useFormContext, useFieldArray } from "react-hook-form" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { Label } from "@/components/ui/label" +import { CalendarIcon, Eye, Calculator, AlertCircle } from "lucide-react" +import { format } from "date-fns" +import { cn, formatCurrency } from "@/lib/utils" +import { useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +interface QuotationItemsTableProps { + prItems: any[] +} + +export default function QuotationItemsTable({ prItems }: QuotationItemsTableProps) { + const { control, register, setValue, watch } = useFormContext() + const { fields } = useFieldArray({ + control, + name: "quotationItems" + }) + + const [selectedItem, setSelectedItem] = useState<any>(null) + const [showDetail, setShowDetail] = useState(false) + + const currency = watch("vendorCurrency") || "USD" + const quotationItems = watch("quotationItems") + + // 단가 * 수량 계산 + const calculateTotal = (index: number) => { + const item = quotationItems[index] + const prItem = prItems[index] + if (item && prItem) { + const total = (item.unitPrice || 0) * (prItem.quantity || 0) + setValue(`quotationItems.${index}.totalPrice`, total) + } + } + + // 할인 적용 + const applyDiscount = (index: number) => { + const item = quotationItems[index] + const prItem = prItems[index] + if (item && prItem && item.discountRate) { + const originalTotal = (item.unitPrice || 0) * (prItem.quantity || 0) + const discountAmount = originalTotal * (item.discountRate / 100) + const finalTotal = originalTotal - discountAmount + setValue(`quotationItems.${index}.totalPrice`, finalTotal) + } + } + + const totalAmount = quotationItems?.reduce( + (sum: number, item: any) => sum + (item.totalPrice || 0), 0 + ) || 0 + + // 상세 정보 다이얼로그 + const ItemDetailDialog = ({ item, prItem, index }: any) => ( + <Dialog open={showDetail} onOpenChange={setShowDetail}> + <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>견적 상세 정보</DialogTitle> + <DialogDescription> + {prItem.materialCode} - {prItem.materialDescription} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* PR 아이템 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">PR 아이템 정보</CardTitle> + </CardHeader> + <CardContent className="grid grid-cols-2 gap-4"> + <div> + <Label className="text-xs text-muted-foreground">PR 번호</Label> + <p className="font-medium">{prItem.prNo}</p> + </div> + <div> + <Label className="text-xs text-muted-foreground">자재 코드</Label> + <p className="font-medium">{prItem.materialCode}</p> + </div> + <div> + <Label className="text-xs text-muted-foreground">수량</Label> + <p className="font-medium">{prItem.quantity} {prItem.uom}</p> + </div> + <div> + <Label className="text-xs text-muted-foreground">요청 납기일</Label> + <p className="font-medium"> + {prItem.deliveryDate ? format(new Date(prItem.deliveryDate), "yyyy-MM-dd") : '-'} + </p> + </div> + {prItem.specNo && ( + <div> + <Label className="text-xs text-muted-foreground">스펙 번호</Label> + <p className="font-medium">{prItem.specNo}</p> + </div> + )} + {prItem.trackingNo && ( + <div> + <Label className="text-xs text-muted-foreground">추적 번호</Label> + <p className="font-medium">{prItem.trackingNo}</p> + </div> + )} + </CardContent> + </Card> + + {/* 제조사 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">제조사 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor={`manufacturer-${index}`}>제조사</Label> + <Input + id={`manufacturer-${index}`} + {...register(`quotationItems.${index}.manufacturer`)} + placeholder="제조사 입력" + /> + </div> + <div className="space-y-2"> + <Label htmlFor={`manufacturerCountry-${index}`}>제조국</Label> + <Input + id={`manufacturerCountry-${index}`} + {...register(`quotationItems.${index}.manufacturerCountry`)} + placeholder="제조국 입력" + /> + </div> + </div> + <div className="space-y-2"> + <Label htmlFor={`modelNo-${index}`}>모델 번호</Label> + <Input + id={`modelNo-${index}`} + {...register(`quotationItems.${index}.modelNo`)} + placeholder="모델 번호 입력" + /> + </div> + </CardContent> + </Card> + + {/* 기술 준수 및 대안 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">기술 사양</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="flex items-center space-x-2"> + <Checkbox + id={`technicalCompliance-${index}`} + checked={watch(`quotationItems.${index}.technicalCompliance`)} + onCheckedChange={(checked) => + setValue(`quotationItems.${index}.technicalCompliance`, checked) + } + /> + <Label htmlFor={`technicalCompliance-${index}`}> + 기술 사양 준수 + </Label> + </div> + + {!watch(`quotationItems.${index}.technicalCompliance`) && ( + <div className="space-y-2"> + <Label htmlFor={`alternativeProposal-${index}`}> + 대안 제안 <span className="text-red-500">*</span> + </Label> + <Textarea + id={`alternativeProposal-${index}`} + {...register(`quotationItems.${index}.alternativeProposal`)} + placeholder="기술 사양을 준수하지 않는 경우 대안을 제시해주세요" + className="min-h-[100px]" + /> + </div> + )} + + <div className="space-y-2"> + <Label htmlFor={`deviationReason-${index}`}>편차 사유</Label> + <Textarea + id={`deviationReason-${index}`} + {...register(`quotationItems.${index}.deviationReason`)} + placeholder="요구사항과 다른 부분이 있는 경우 사유를 입력하세요" + className="min-h-[80px]" + /> + </div> + </CardContent> + </Card> + + {/* 할인 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">할인 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor={`discountRate-${index}`}>할인율 (%)</Label> + <Input + id={`discountRate-${index}`} + type="number" + step="0.01" + {...register(`quotationItems.${index}.discountRate`, { valueAsNumber: true })} + onChange={(e) => { + setValue(`quotationItems.${index}.discountRate`, parseFloat(e.target.value)) + applyDiscount(index) + }} + placeholder="0.00" + /> + </div> + <div className="space-y-2"> + <Label>할인 금액</Label> + <div className="h-10 px-3 py-2 border rounded-md bg-muted"> + {formatCurrency( + (watch(`quotationItems.${index}.unitPrice`) || 0) * + (prItem.quantity || 0) * + ((watch(`quotationItems.${index}.discountRate`) || 0) / 100), + currency + )} + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 비고 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">비고</CardTitle> + </CardHeader> + <CardContent> + <Textarea + {...register(`quotationItems.${index}.itemRemark`)} + placeholder="아이템별 비고사항을 입력하세요" + className="min-h-[100px]" + /> + </CardContent> + </Card> + </div> + </DialogContent> + </Dialog> + ) + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>견적 품목</CardTitle> + <CardDescription> + 각 PR 아이템에 대한 견적 단가와 정보를 입력하세요 + </CardDescription> + </div> + <div className="text-right"> + <p className="text-sm text-muted-foreground">총 견적금액</p> + <p className="text-2xl font-bold text-primary"> + {formatCurrency(totalAmount, currency)} + </p> + </div> + </div> + </CardHeader> + <CardContent> + <div className="overflow-x-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px]">No</TableHead> + <TableHead className="w-[100px]">PR No</TableHead> + <TableHead className="min-w-[150px]">자재코드</TableHead> + <TableHead className="min-w-[200px]">자재설명</TableHead> + <TableHead className="text-right w-[100px]">수량</TableHead> + <TableHead className="w-[150px]">단가</TableHead> + <TableHead className="text-right w-[150px]">총액</TableHead> + <TableHead className="w-[150px]">납기일</TableHead> + <TableHead className="w-[100px]">리드타임</TableHead> + <TableHead className="w-[80px]">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => { + const prItem = prItems[index] + const quotationItem = quotationItems[index] + const isMajor = prItem?.majorYn + + return ( + <TableRow key={field.id} className={isMajor ? "bg-yellow-50" : ""}> + <TableCell> + <div className="flex items-center gap-2"> + {prItem?.rfqItem || index + 1} + {isMajor && ( + <Badge variant="secondary" className="text-xs"> + 주요 + </Badge> + )} + </div> + </TableCell> + <TableCell className="font-mono text-xs"> + {prItem?.prNo} + </TableCell> + <TableCell className="font-mono text-xs"> + {prItem?.materialCode} + </TableCell> + <TableCell> + <div className="max-w-[200px]"> + <p className="truncate text-sm" title={prItem?.materialDescription}> + {prItem?.materialDescription} + </p> + {prItem?.size && ( + <p className="text-xs text-muted-foreground"> + 사이즈: {prItem.size} + </p> + )} + </div> + </TableCell> + <TableCell className="text-right"> + {prItem?.quantity} {prItem?.uom} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Input + type="number" + step="0.01" + {...register(`quotationItems.${index}.unitPrice`, { valueAsNumber: true })} + onChange={(e) => { + setValue(`quotationItems.${index}.unitPrice`, parseFloat(e.target.value)) + calculateTotal(index) + }} + className="w-[120px]" + placeholder="0.00" + /> + <span className="text-xs text-muted-foreground"> + {currency} + </span> + </div> + </TableCell> + <TableCell className="text-right font-medium"> + {formatCurrency(quotationItem?.totalPrice || 0, currency)} + </TableCell> + <TableCell> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + size="sm" + className={cn( + "w-[130px] justify-start text-left font-normal", + !quotationItem?.vendorDeliveryDate && "text-muted-foreground" + )} + > + <CalendarIcon className="mr-2 h-3 w-3" /> + {quotationItem?.vendorDeliveryDate + ? format(quotationItem.vendorDeliveryDate, "yyyy-MM-dd") + : "선택"} + </Button> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={quotationItem?.vendorDeliveryDate} + onSelect={(date) => setValue(`quotationItems.${index}.vendorDeliveryDate`, date)} + initialFocus + /> + </PopoverContent> + </Popover> + {prItem?.deliveryDate && quotationItem?.vendorDeliveryDate && + new Date(quotationItem.vendorDeliveryDate) > new Date(prItem.deliveryDate) && ( + <div className="mt-1"> + <Badge variant="destructive" className="text-xs"> + 지연 + </Badge> + </div> + )} + </TableCell> + <TableCell> + <Input + type="number" + {...register(`quotationItems.${index}.leadTime`, { valueAsNumber: true })} + className="w-[80px]" + placeholder="일" + /> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => { + setSelectedItem({ item: quotationItem, prItem, index }) + setShowDetail(true) + }} + > + <Eye className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </div> + + {/* 총액 요약 */} + <div className="mt-4 flex justify-end"> + <Card className="w-[400px]"> + <CardContent className="pt-4"> + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span className="text-muted-foreground">소계</span> + <span>{formatCurrency(totalAmount, currency)}</span> + </div> + <div className="flex justify-between text-sm"> + <span className="text-muted-foreground">통화</span> + <span>{currency}</span> + </div> + <div className="border-t pt-2"> + <div className="flex justify-between"> + <span className="font-semibold">총 견적금액</span> + <span className="text-xl font-bold text-primary"> + {formatCurrency(totalAmount, currency)} + </span> + </div> + </div> + </div> + </CardContent> + </Card> + </div> + </CardContent> + + {/* 상세 다이얼로그 */} + {selectedItem && ( + <ItemDetailDialog + item={selectedItem.item} + prItem={selectedItem.prItem} + index={selectedItem.index} + /> + )} + </Card> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/editor/rfq-info-header.tsx b/lib/rfq-last/vendor-response/editor/rfq-info-header.tsx new file mode 100644 index 00000000..1078b20e --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/rfq-info-header.tsx @@ -0,0 +1,213 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { formatDate } from "@/lib/utils" +import { Building2, Package, Calendar, FileText, User, Users, Ship, Award, Anchor, ArrowLeft } from "lucide-react" +import { useRouter } from "next/navigation" +import { Button } from "@/components/ui/button"; + +interface RfqInfoHeaderProps { + rfq: any + rfqDetail: any + vendor: any +} + +export default function RfqInfoHeader({ rfq, rfqDetail, vendor }: RfqInfoHeaderProps) { + const majorMaterial = rfq.rfqPrItems?.find(v => v.majorYn) + const router = useRouter() + + const handleGoBack = () => { + router.push("/partners/rfq-last"); + }; + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle className="text-2xl"> + 견적서 작성 + <Badge variant="outline" className="ml-3"> + {rfq.rfqCode} + </Badge> + </CardTitle> + <Button + variant="ghost" + size="sm" + onClick={handleGoBack} + className="gap-2" + > + <ArrowLeft className="h-4 w-4" /> + 목록으로 + </Button> + </div> + </CardHeader> + <CardContent className="space-y-4"> + {/* 기본 정보 섹션 */} + <div className="grid grid-cols-2 lg:grid-cols-4 gap-6"> + {/* 프로젝트 정보 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Building2 className="h-4 w-4" /> + <span>프로젝트</span> + </div> + <p className="font-medium">{rfq.project?.name || '-'}</p> + <p className="text-xs text-muted-foreground">{rfq.project?.code || '-'}</p> + </div> + + {/* 패키지 정보 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Package className="h-4 w-4" /> + <span>패키지</span> + </div> + <p className="font-medium">{rfq.packageName || '-'}</p> + <p className="text-xs text-muted-foreground">{rfq.packageNo || '-'}</p> + </div> + + {/* 자재 그룹 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Package className="h-4 w-4" /> + <span>자재 그룹</span> + </div> + <p className="font-medium">{majorMaterial?.materialCategory || '-'}</p> + <p className="text-xs text-muted-foreground">{majorMaterial?.materialDescription || '-'}</p> + </div> + + {/* 마감일 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Calendar className="h-4 w-4" /> + <span>마감일</span> + </div> + <p className="font-medium"> + {rfq.dueDate ? formatDate(new Date(rfq.dueDate)) : '-'} + </p> + {rfq.dueDate && ( + <p className="text-xs text-muted-foreground"> + {(() => { + const now = new Date() + const dueDate = new Date(rfq.dueDate) + const diffTime = dueDate.getTime() - now.getTime() + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + + if (diffDays < 0) { + return <span className="text-red-600">마감일 초과</span> + } else if (diffDays === 0) { + return <span className="text-orange-600">오늘 마감</span> + } else if (diffDays === 1) { + return <span className="text-orange-600">내일 마감</span> + } else { + return `${diffDays}일 남음` + } + })()} + </p> + )} + </div> + + {/* 구매담당자 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <User className="h-4 w-4" /> + <span>구매담당자</span> + </div> + <p className="font-medium">{rfq.picName || '-'}</p> + </div> + + {/* 설계담당자 */} + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <User className="h-4 w-4" /> + <span>설계담당자</span> + </div> + <p className="font-medium">{rfq.EngPicName || '-'}</p> + </div> + + {/* RFQ 제목 및 타입 (F로 시작하는 경우만) */} + {rfq.rfqCode && rfq.rfqCode.startsWith("F") && ( + <> + <div className="space-y-1 col-span-2"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <FileText className="h-4 w-4" /> + <span>견적 제목</span> + </div> + <p className="font-medium">{rfq.rfqTitle || '-'}</p> + </div> + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <FileText className="h-4 w-4" /> + <span>견적 종류</span> + </div> + <p className="font-medium">{rfq.rfqType || '-'}</p> + </div> + </> + )} + </div> + + {/* 프로젝트 상세 정보 섹션 (별도 div) */} + + <div className="pt-4 border-t"> + <h3 className="text-sm font-semibold mb-3">프로젝트 상세 정보</h3> + <div className="grid grid-cols-2 lg:grid-cols-5 gap-4"> + {/* 고객정보 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Users className="h-4 w-4" /> + <span>고객정보</span> + </div> + <p className="font-medium">{rfq.project?.OWN_NM || '-'}</p> + </div> + )} + + {/* 선급 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Award className="h-4 w-4" /> + <span>선급</span> + </div> + <p className="font-medium">{rfq.project?.CLS_1 || '-'}</p> + </div> + )} + + {/* 선종 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Ship className="h-4 w-4" /> + <span>선종</span> + </div> + <p className="font-medium">{rfq.project?.SKND || '-'}</p> + </div> + )} + + {/* 척수 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Anchor className="h-4 w-4" /> + <span>척수</span> + </div> + <p className="font-medium">{rfq.project?.TOT_CNRT_CNT || '-'}척</p> + </div> + )} + + {/* 계약일 */} + {( + <div className="space-y-1"> + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Calendar className="h-4 w-4" /> + <span>계약일</span> + </div> + <p className="font-medium">{rfq.project && rfq.project.CNRT_DT ? formatDate(new Date(rfq.project.CNRT_DT)) : '-'}</p> + </div> + )} + </div> + </div> + + </CardContent> + </Card> + ) +}
\ 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 new file mode 100644 index 00000000..c146e42b --- /dev/null +++ b/lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx @@ -0,0 +1,477 @@ +"use client" + +import { useState } from "react" +import { useForm, FormProvider } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { useRouter } from "next/navigation" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" +import RfqInfoHeader from "./rfq-info-header" +import CommercialTermsForm from "./commercial-terms-form" +import QuotationItemsTable from "./quotation-items-table" +import AttachmentsUpload from "./attachments-upload" +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" +import { Alert, AlertDescription } from "@/components/ui/alert" + +// 폼 스키마 정의 +const vendorResponseSchema = z.object({ + // 상업 조건 + vendorCurrency: z.string().optional(), + vendorPaymentTermsCode: z.string().optional(), + vendorIncotermsCode: z.string().optional(), + vendorIncotermsDetail: z.string().optional(), + vendorDeliveryDate: z.date().optional().nullable(), + vendorContractDuration: z.string().optional(), + vendorTaxCode: z.string().optional(), + vendorPlaceOfShipping: z.string().optional(), + vendorPlaceOfDestination: z.string().optional(), + + // 초도품관리 + vendorFirstYn: z.boolean().optional(), + vendorFirstDescription: z.string().optional(), + vendorFirstAcceptance: z.enum(["수용", "부분수용", "거부"]).optional().nullable(), + + // Spare part + vendorSparepartYn: z.boolean().optional(), + vendorSparepartDescription: z.string().optional(), + vendorSparepartAcceptance: z.enum(["수용", "부분수용", "거부"]).optional().nullable(), + + // 연동제 + vendorMaterialPriceRelatedYn: z.boolean().optional(), + vendorMaterialPriceRelatedReason: z.string().optional(), + + // 변경 사유 + currencyReason: z.string().optional(), + paymentTermsReason: z.string().optional(), + deliveryDateReason: z.string().optional(), + incotermsReason: z.string().optional(), + taxReason: z.string().optional(), + shippingReason: z.string().optional(), + + // 비고 + generalRemark: z.string().optional(), + technicalProposal: z.string().optional(), + + // 견적 아이템 + quotationItems: z.array(z.object({ + rfqPrItemId: z.number(), + unitPrice: z.number().min(0), + totalPrice: z.number().min(0), + vendorDeliveryDate: z.date().optional().nullable(), + leadTime: z.number().optional(), + manufacturer: z.string().optional(), + manufacturerCountry: z.string().optional(), + modelNo: z.string().optional(), + technicalCompliance: z.boolean(), + alternativeProposal: z.string().optional(), + discountRate: z.number().optional(), + itemRemark: z.string().optional(), + deviationReason: z.string().optional(), + })) +}) + +type VendorResponseFormData = z.infer<typeof vendorResponseSchema> + +interface VendorResponseEditorProps { + rfq: any + rfqDetail: any + prItems: any[] + vendor: any + existingResponse?: any + userId: number + basicContracts?: any[] // 추가 +} + +export default function VendorResponseEditor({ + rfq, + rfqDetail, + prItems, + vendor, + existingResponse, + userId, + basicContracts = [] // 추가 + +}: VendorResponseEditorProps) { + const router = useRouter() + const [loading, setLoading] = useState(false) + const [activeTab, setActiveTab] = useState("info") + const [attachments, setAttachments] = useState<File[]>([]) + const [uploadProgress, setUploadProgress] = useState(0) // 추가 + + + // Form 초기값 설정 + const defaultValues: VendorResponseFormData = { + vendorCurrency: existingResponse?.vendorCurrency || rfqDetail.currency, + vendorPaymentTermsCode: existingResponse?.vendorPaymentTermsCode || rfqDetail.paymentTermsCode, + vendorIncotermsCode: existingResponse?.vendorIncotermsCode || rfqDetail.incotermsCode, + vendorIncotermsDetail: existingResponse?.vendorIncotermsDetail || rfqDetail.incotermsDetail, + vendorDeliveryDate: existingResponse?.vendorDeliveryDate ? new Date(existingResponse.vendorDeliveryDate) : + rfqDetail.deliveryDate ? new Date(rfqDetail.deliveryDate) : null, + vendorContractDuration: existingResponse?.vendorContractDuration || rfqDetail.contractDuration, + vendorTaxCode: existingResponse?.vendorTaxCode || rfqDetail.taxCode, + vendorPlaceOfShipping: existingResponse?.vendorPlaceOfShipping || rfqDetail.placeOfShipping, + vendorPlaceOfDestination: existingResponse?.vendorPlaceOfDestination || rfqDetail.placeOfDestination, + + vendorFirstYn: existingResponse?.vendorFirstYn ?? rfqDetail.firstYn, + vendorFirstDescription: existingResponse?.vendorFirstDescription || "", + vendorFirstAcceptance: existingResponse?.vendorFirstAcceptance || null, + + vendorSparepartYn: existingResponse?.vendorSparepartYn ?? rfqDetail.sparepartYn, + vendorSparepartDescription: existingResponse?.vendorSparepartDescription || "", + vendorSparepartAcceptance: existingResponse?.vendorSparepartAcceptance || null, + + vendorMaterialPriceRelatedYn: existingResponse?.vendorMaterialPriceRelatedYn ?? rfqDetail.materialPriceRelatedYn, + vendorMaterialPriceRelatedReason: existingResponse?.vendorMaterialPriceRelatedReason || "", + + currencyReason: existingResponse?.currencyReason || "", + paymentTermsReason: existingResponse?.paymentTermsReason || "", + deliveryDateReason: existingResponse?.deliveryDateReason || "", + incotermsReason: existingResponse?.incotermsReason || "", + taxReason: existingResponse?.taxReason || "", + shippingReason: existingResponse?.shippingReason || "", + + generalRemark: existingResponse?.generalRemark || "", + technicalProposal: existingResponse?.technicalProposal || "", + + quotationItems: prItems.map(item => { + const existingItem = existingResponse?.quotationItems?.find( + (q: any) => q.rfqPrItemId === item.id + ) + return { + rfqPrItemId: item.id, + unitPrice: existingItem?.unitPrice || 0, + totalPrice: existingItem?.totalPrice || 0, + vendorDeliveryDate: existingItem?.vendorDeliveryDate ? new Date(existingItem.vendorDeliveryDate) : null, + leadTime: existingItem?.leadTime || undefined, + manufacturer: existingItem?.manufacturer || "", + manufacturerCountry: existingItem?.manufacturerCountry || "", + modelNo: existingItem?.modelNo || "", + technicalCompliance: existingItem?.technicalCompliance ?? true, + alternativeProposal: existingItem?.alternativeProposal || "", + discountRate: existingItem?.discountRate || undefined, + itemRemark: existingItem?.itemRemark || "", + deviationReason: existingItem?.deviationReason || "", + } + }) + } + + const methods = useForm<VendorResponseFormData>({ + resolver: zodResolver(vendorResponseSchema), + defaultValues + }) + + const onSubmit = async (data: VendorResponseFormData, isSubmit: boolean = false) => { + setLoading(true) + setUploadProgress(0) + + try { + const formData = new FormData() + + // 기본 데이터 추가 + formData.append('data', JSON.stringify({ + ...data, + rfqsLastId: rfq.id, + rfqLastDetailsId: rfqDetail.id, + vendorId: vendor.id, + status: isSubmit ? "제출완료" : "작성중", + submittedAt: isSubmit ? new Date().toISOString() : null, + submittedBy: isSubmit ? userId : null, + totalAmount: data.quotationItems.reduce((sum, item) => sum + item.totalPrice, 0), + updatedBy: userId + })) + + // 첨부파일 추가 + attachments.forEach((file, index) => { + formData.append(`attachments`, file) + }) + + // const response = await fetch(`/api/partners/rfq-last/${rfq.id}/response`, { + // method: existingResponse ? 'PUT' : 'POST', + // body: formData + // }) + + // if (!response.ok) { + // throw new Error('응답 저장에 실패했습니다.') + // } + + // XMLHttpRequest 사용하여 업로드 진행률 추적 + const xhr = new XMLHttpRequest() + + // Promise로 감싸서 async/await 사용 가능하게 + const uploadPromise = new Promise((resolve, reject) => { + // 업로드 진행률 이벤트 + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const percentComplete = Math.round((event.loaded / event.total) * 100) + setUploadProgress(percentComplete) + } + }) + + // 완료 이벤트 + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + setUploadProgress(100) + resolve(JSON.parse(xhr.responseText)) + } else { + reject(new Error('응답 저장에 실패했습니다.')) + } + }) + + // 에러 이벤트 + xhr.addEventListener('error', () => { + reject(new Error('네트워크 오류가 발생했습니다.')) + }) + + // 요청 전송 + xhr.open(existingResponse ? 'PUT' : 'POST', `/api/partners/rfq-last/${rfq.id}/response`) + xhr.send(formData) + }) + + await uploadPromise + + toast.success(isSubmit ? "견적서가 제출되었습니다." : "견적서가 저장되었습니다.") + router.push('/partners/rfq-last') + router.refresh() + } catch (error) { + console.error('Error:', error) + toast.error("오류가 발생했습니다.") + } finally { + setLoading(false) + } + } + + const totalAmount = methods.watch('quotationItems')?.reduce( + (sum, item) => sum + (item.totalPrice || 0), 0 + ) || 0 + + + const allContractsSigned = basicContracts.length === 0 || + basicContracts.every(contract => contract.signedAt); + + return ( + <FormProvider {...methods}> + <form onSubmit={methods.handleSubmit((data) => onSubmit(data, false))}> + <div className="space-y-6"> + {/* 헤더 정보 */} + <RfqInfoHeader rfq={rfq} rfqDetail={rfqDetail} vendor={vendor} /> + + {/* 견적 총액 표시 */} + {totalAmount > 0 && ( + <Card> + <CardContent className="pt-6"> + <div className="flex items-center justify-between"> + <span className="text-lg font-medium">견적 총액</span> + <span className="text-2xl font-bold text-primary"> + {formatCurrency(totalAmount, methods.watch('vendorCurrency') || 'USD')} + </span> + </div> + </CardContent> + </Card> + )} + + {/* 탭 콘텐츠 */} + <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> + <TabsList className="grid w-full grid-cols-4"> + <TabsTrigger value="info">기본계약</TabsTrigger> + <TabsTrigger value="terms">상업조건</TabsTrigger> + <TabsTrigger value="items">견적품목</TabsTrigger> + <TabsTrigger value="attachments">첨부파일</TabsTrigger> + </TabsList> + + <TabsContent value="info" className="mt-6"> + <Card> + <CardHeader> + <CardTitle>기본계약 정보</CardTitle> + <CardDescription> + 이 RFQ에 요청된 기본계약 목록 및 상태입니다 + </CardDescription> + </CardHeader> + <CardContent> + {basicContracts.length > 0 ? ( + <div className="space-y-4"> + {/* 계약 목록 - 그리드 레이아웃 */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> + {basicContracts.map((contract) => ( + <div + key={contract.id} + className="p-3 border rounded-lg bg-card hover:bg-muted/50 transition-colors" + > + <div className="flex items-start gap-2"> + <div className="p-1.5 bg-primary/10 rounded"> + <Shield className="h-3.5 w-3.5 text-primary" /> + </div> + <div className="flex-1 min-w-0"> + <h4 className="font-medium text-sm truncate" title={contract.templateName}> + {contract.templateName} + </h4> + <Badge + variant={contract.signedAt ? "success" : "warning"} + className="text-xs mt-1.5" + > + {contract.signedAt ? ( + <> + <CheckCircle className="h-3 w-3 mr-1" /> + 서명완료 + </> + ) : ( + <> + <Clock className="h-3 w-3 mr-1" /> + 서명대기 + </> + )} + </Badge> + <p className="text-xs text-muted-foreground mt-1"> + {contract.signedAt + ? `${formatDate(new Date(contract.signedAt))}` + : contract.deadline + ? `~${formatDate(new Date(contract.deadline))}` + : '마감일 없음'} + </p> + </div> + </div> + </div> + ))} + </div> + + {/* 서명 상태 요약 및 액션 */} + {basicContracts.some(contract => !contract.signedAt) ? ( + <div className="flex items-center justify-between p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900 rounded-lg"> + <div className="flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-amber-600" /> + <div> + <p className="text-sm font-medium"> + 서명 대기: {basicContracts.filter(c => !c.signedAt).length}/{basicContracts.length}개 + </p> + <p className="text-xs text-muted-foreground"> + 견적서 제출 전 모든 계약서 서명 필요 + </p> + </div> + </div> + <Button + type="button" + size="sm" + onClick={() => router.push(`/partners/basic-contract`)} + > + 서명하기 + </Button> + </div> + ) : ( + <Alert className="border-green-200 bg-green-50 dark:bg-green-950/20"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <AlertDescription className="text-sm"> + 모든 기본계약 서명 완료 + </AlertDescription> + </Alert> + )} + </div> + ) : ( + <div className="text-center py-8"> + <FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> + <p className="text-muted-foreground"> + 이 RFQ에 요청된 기본계약이 없습니다 + </p> + </div> + )} +</CardContent> + </Card> + </TabsContent> + + <TabsContent value="terms" className="mt-6"> + <CommercialTermsForm rfqDetail={rfqDetail} /> + </TabsContent> + + <TabsContent value="items" className="mt-6"> + <QuotationItemsTable prItems={prItems} /> + </TabsContent> + + <TabsContent value="attachments" className="mt-6"> + <AttachmentsUpload + attachments={attachments} + onAttachmentsChange={setAttachments} + existingAttachments={existingResponse?.attachments} + /> + </TabsContent> + </Tabs> + + {/* 하단 액션 버튼 */} + {loading && uploadProgress > 0 && ( + <Card> + <CardContent className="pt-6"> + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span className="flex items-center gap-2"> + <Upload className="h-4 w-4 animate-pulse" /> + 파일 업로드 중... + </span> + <span className="font-medium">{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} className="h-2" /> + <p className="text-xs text-muted-foreground"> + 대용량 파일 업로드 시 시간이 걸릴 수 있습니다. 창을 닫지 마세요. + </p> + </div> + </CardContent> + </Card> + )} + <div className="flex justify-end gap-3"> + <Button + type="button" + variant="outline" + onClick={() => router.back()} + disabled={loading} + > + 취소 + </Button> + <Button + type="submit" + variant="secondary" + disabled={loading} + > + {loading ? ( + <> + <div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" /> + 처리중... + </> + ) : ( + <> + <Save className="h-4 w-4 mr-2" /> + 임시저장 + </> + )} + </Button> + <Button + type="button" + variant="default" + onClick={methods.handleSubmit((data) => onSubmit(data, true))} + disabled={loading || !allContractsSigned} + > + {!allContractsSigned ? ( + <> + <AlertCircle className="h-4 w-4 mr-2" /> + 기본계약 서명 필요 + </> + ) : loading ? ( + <> + <div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-current border-t-transparent" /> + 처리중... + </> + ) : ( + <> + <Send className="h-4 w-4 mr-2" /> + 견적서 제출 + </> + )} + </Button> + </div> + + </div> + </form> + </FormProvider> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/participation-dialog.tsx b/lib/rfq-last/vendor-response/participation-dialog.tsx new file mode 100644 index 00000000..a7337ac2 --- /dev/null +++ b/lib/rfq-last/vendor-response/participation-dialog.tsx @@ -0,0 +1,230 @@ +// components/vendor/participation-dialog.tsx + +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { useToast } from "@/hooks/use-toast" +import { updateParticipationStatus } from "@/lib/rfq-last/vendor-response/service" +import { CheckCircle, XCircle } from "lucide-react" + +interface ParticipationDialogProps { + rfqId: number + rfqCode: string + rfqLastDetailsId: number + currentStatus?: string + onClose: () => void +} + +export function ParticipationDialog({ + rfqId, + rfqCode, + rfqLastDetailsId, + currentStatus, + onClose, +}: ParticipationDialogProps) { + const router = useRouter() + const [showDeclineDialog, setShowDeclineDialog] = React.useState(false) + const [showConfirmDialog, setShowConfirmDialog] = React.useState(false) + const [declineReason, setDeclineReason] = React.useState("") + const [isLoading, setIsLoading] = React.useState(false) + const { toast } = useToast(); + + const handleParticipate = () => { + setShowConfirmDialog(true) + } + + const handleDecline = () => { + setShowDeclineDialog(true) + } + + const confirmParticipation = async () => { + setIsLoading(true) + try { + const result = await updateParticipationStatus({ + rfqId, + rfqLastDetailsId, + participationStatus: "참여", + }) + + if (result.success) { + toast({ + title: "참여 확정", + description: result.message, + }) + // router.push(`/partners/rfq-last/${rfqId}`) + router.refresh() + } else { + toast({ + title: "오류", + description: result.message, + variant: "destructive", + }) + } + } catch (error) { + toast({ + title: "오류", + description: "참여 처리 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + onClose() + } + } + + const confirmDecline = async () => { + if (!declineReason.trim()) { + toast({ + title: "불참 사유 필요", + description: "불참 사유를 입력해주세요.", + variant: "destructive", + }) + return + } + + setIsLoading(true) + try { + const result = await updateParticipationStatus({ + rfqId, + rfqLastDetailsId, + participationStatus: "불참", + nonParticipationReason: declineReason, + }) + + if (result.success) { + toast({ + title: "불참 처리 완료", + description: result.message, + }) + router.refresh() + } else { + toast({ + title: "오류", + description: result.message, + variant: "destructive", + }) + } + } catch (error) { + toast({ + title: "오류", + description: "불참 처리 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + onClose() + } + } + + return ( + <> + {/* 메인 다이얼로그 */} + <AlertDialog open={true} onOpenChange={onClose}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>견적 참여 여부 결정</AlertDialogTitle> + <AlertDialogDescription> + {rfqCode} 견적 요청에 참여하시겠습니까? + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <Button + variant="outline" + onClick={handleDecline} + disabled={isLoading} + > + <XCircle className="mr-2 h-4 w-4" /> + 불참 + </Button> + <Button + onClick={handleParticipate} + disabled={isLoading} + > + <CheckCircle className="mr-2 h-4 w-4" /> + 참여 + </Button> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + {/* 참여 확인 다이얼로그 */} + <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>견적 참여 확정</AlertDialogTitle> + <AlertDialogDescription> + 견적 참여를 확정하시면 견적서 작성 페이지로 이동합니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={confirmParticipation}> + 확정 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + + {/* 불참 사유 입력 다이얼로그 */} + <Dialog open={showDeclineDialog} onOpenChange={setShowDeclineDialog}> + <DialogContent> + <DialogHeader> + <DialogTitle>견적 불참</DialogTitle> + <DialogDescription> + 불참 사유를 입력해주세요. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label htmlFor="reason">불참 사유</Label> + <Textarea + id="reason" + placeholder="불참 사유를 입력하세요..." + value={declineReason} + onChange={(e) => setDeclineReason(e.target.value)} + rows={4} + /> + </div> + </div> + <DialogFooter> + <Button + variant="outline" + onClick={() => setShowDeclineDialog(false)} + > + 취소 + </Button> + <Button + onClick={confirmDecline} + disabled={isLoading || !declineReason.trim()} + > + 불참 확정 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx new file mode 100644 index 00000000..cfe24d73 --- /dev/null +++ b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx @@ -0,0 +1,407 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Download, FileText, Eye, ExternalLink, Loader2 } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "sonner" +import { RfqsLastView } from "@/db/schema" +import { getRfqAttachmentsAction } from "../service" +import { downloadFile, quickPreview, smartFileAction, formatFileSize, getFileInfo } from "@/lib/file-download" + +// 첨부파일 타입 +interface RfqAttachment { + attachmentId: number + attachmentType: string + serialNo: string + description: string | null + currentRevision: string + fileName: string + originalFileName: string + filePath: string + fileSize: number | null + fileType: string | null + createdByName: string | null + createdAt: Date | null + updatedAt: Date | null + revisionComment?: string | null +} + +interface RfqAttachmentsDialogProps { + isOpen: boolean + onClose: () => void + rfqData: RfqsLastView +} + +export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachmentsDialogProps) { + const [attachments, setAttachments] = React.useState<RfqAttachment[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [downloadingFiles, setDownloadingFiles] = React.useState<Set<number>>(new Set()) + const [isDownloadingAll, setIsDownloadingAll] = React.useState(false); + + + const handleDownloadAll = async () => { + setIsDownloadingAll(true); + try { + + const rfqId = rfqData.id + + const attachments = await getRfqAttachmentsAction(rfqId); + + if (!attachments.success || attachments.data.length === 0) { + toast.error(result.error || "다운로드할 파일이 없습니다"); + } + + + // 2. ZIP 파일 생성 (서버에서) + const response = await fetch(`/api/rfq/attachments/download-all`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rfqId, + files: attachments.data.map(a => ({ + path: a.filePath, + name: a.originalFileName + })) + }) + }); + + if (!response.ok) throw new Error('ZIP 생성 실패'); + + // 3. ZIP 다운로드 + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + // RFQ 코드를 포함한 파일명 + const date = new Date().toISOString().slice(0, 10); + link.download = `RFQ_${rfqId}_attachments_${date}.zip`; + link.href = url; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + } finally { + setIsDownloadingAll(false); + } + }; + + // 첨부파일 목록 로드 + React.useEffect(() => { + if (!isOpen || !rfqData.id) return + + const loadAttachments = async () => { + setIsLoading(true) + try { + const result = await getRfqAttachmentsAction(rfqData.id) + + if (result.success) { + setAttachments(result.data) + } else { + toast.error(result.error || "첨부파일을 불러오는데 실패했습니다") + setAttachments([]) + } + } catch (error) { + console.error("첨부파일 로드 오류:", error) + toast.error("첨부파일을 불러오는데 실패했습니다") + setAttachments([]) + } finally { + setIsLoading(false) + } + } + + loadAttachments() + }, [isOpen, rfqData.id]) + + // 파일 다운로드 핸들러 + const handleDownload = async (attachment: RfqAttachment) => { + const attachmentId = attachment.attachmentId + setDownloadingFiles(prev => new Set([...prev, attachmentId])) + + try { + const result = await downloadFile( + attachment.filePath, + attachment.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onSuccess: (fileName, fileSize) => { + console.log(`다운로드 완료: ${fileName} (${formatFileSize(fileSize || 0)})`) + }, + onError: (error) => { + console.error(`다운로드 실패: ${error}`) + } + } + ) + + if (!result.success) { + console.error("다운로드 결과:", result) + } + } catch (error) { + console.error("파일 다운로드 오류:", error) + toast.error("파일 다운로드에 실패했습니다") + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(attachmentId) + return newSet + }) + } + } + + // 파일 미리보기 핸들러 + const handlePreview = async (attachment: RfqAttachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + + if (!fileInfo.canPreview) { + toast.info("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다.") + return handleDownload(attachment) + } + + try { + const result = await quickPreview(attachment.filePath, attachment.originalFileName) + + if (!result.success) { + console.error("미리보기 결과:", result) + } + } catch (error) { + console.error("파일 미리보기 오류:", error) + toast.error("파일 미리보기에 실패했습니다") + } + } + + + // 첨부파일 타입별 색상 + const getAttachmentTypeBadgeVariant = (type: string) => { + switch (type.toLowerCase()) { + case "견적요청서": return "default" + case "기술사양서": return "secondary" + case "도면": return "outline" + default: return "outline" + } + } + + return ( + <Dialog open={isOpen} onOpenChange={onClose}> + <DialogContent className="max-w-6xl h-[85vh] flex flex-col"> + <DialogHeader> + <div className="flex items-center justify-between"> + <div> + <DialogTitle>견적 첨부파일</DialogTitle> + <DialogDescription> + {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "견적"} + {attachments.length > 0 && ` (${attachments.length}개 파일)`} + </DialogDescription> + </div> + + </div> + </DialogHeader> + + <ScrollArea className="flex-1"> + {isLoading ? ( + <div className="space-y-3"> + {[...Array(3)].map((_, i) => ( + <div key={i} className="flex items-center space-x-4 p-3 border rounded-lg"> + <Skeleton className="h-8 w-8" /> + <div className="space-y-2 flex-1"> + <Skeleton className="h-4 w-[300px]" /> + <Skeleton className="h-3 w-[200px]" /> + </div> + <div className="flex gap-2"> + <Skeleton className="h-8 w-20" /> + <Skeleton className="h-8 w-20" /> + </div> + </div> + ))} + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">타입</TableHead> + <TableHead>파일명</TableHead> + {/* <TableHead>설명</TableHead> */} + <TableHead className="w-[90px]">리비전</TableHead> + <TableHead className="w-[100px]">크기</TableHead> + <TableHead className="w-[120px]">생성자</TableHead> + <TableHead className="w-[120px]">생성일</TableHead> + <TableHead className="w-[140px]">액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {attachments.length === 0 ? ( + <TableRow> + <TableCell colSpan={8} className="text-center text-muted-foreground py-12"> + <div className="flex flex-col items-center gap-2"> + <FileText className="h-8 w-8 text-muted-foreground" /> + <span>첨부된 파일이 없습니다.</span> + </div> + </TableCell> + </TableRow> + ) : ( + attachments.map((attachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + const isDownloading = downloadingFiles.has(attachment.attachmentId) + + return ( + <TableRow key={attachment.attachmentId}> + <TableCell> + <Badge + variant={getAttachmentTypeBadgeVariant(attachment.attachmentType)} + className="text-xs" + > + {attachment.attachmentType} + </Badge> + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <span className="text-lg">{fileInfo.icon}</span> + <div className="flex flex-col min-w-0"> + <span className="text-sm font-medium truncate" title={attachment.originalFileName}> + {attachment.originalFileName} + </span> + </div> + </div> + </TableCell> + {/* <TableCell> + <span className="text-sm" title={attachment.description || ""}> + {attachment.description || "-"} + </span> + {attachment.revisionComment && ( + <div className="text-xs text-muted-foreground mt-1"> + {attachment.revisionComment} + </div> + )} + </TableCell> */} + <TableCell> + <Badge variant="secondary" className="font-mono text-xs"> + {attachment.currentRevision} + </Badge> + </TableCell> + <TableCell className="text-xs text-muted-foreground"> + {attachment.fileSize ? formatFileSize(attachment.fileSize) : "-"} + </TableCell> + <TableCell className="text-sm"> + {attachment.createdByName || "-"} + </TableCell> + <TableCell className="text-xs text-muted-foreground"> + {attachment.createdAt ? format(new Date(attachment.createdAt), "MM-dd HH:mm") : "-"} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {/* 미리보기 버튼 (미리보기 가능한 파일만) */} + {fileInfo.canPreview && ( + <Button + variant="ghost" + size="sm" + onClick={() => handlePreview(attachment)} + disabled={isDownloading} + title="미리보기" + > + <Eye className="h-4 w-4" /> + </Button> + )} + + {/* 다운로드 버튼 */} + <Button + variant="ghost" + size="sm" + onClick={() => handleDownload(attachment)} + disabled={isDownloading} + title="다운로드" + > + {isDownloading ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + + {/* 스마트 액션 버튼 (메인 액션) */} + {/* <Button + variant="outline" + size="sm" + onClick={() => handleSmartAction(attachment)} + disabled={isDownloading} + className="ml-1" + > + {isDownloading ? ( + <Loader2 className="h-4 w-4 animate-spin mr-1" /> + ) : fileInfo.canPreview ? ( + <Eye className="h-4 w-4 mr-1" /> + ) : ( + <Download className="h-4 w-4 mr-1" /> + )} + {fileInfo.canPreview ? "보기" : "다운로드"} + </Button> */} + </div> + </TableCell> + </TableRow> + ) + }) + )} + </TableBody> + </Table> + )} + </ScrollArea> + <div className="flex items-center justify-between border-t pt-4 text-xs text-muted-foreground"> + {/* 하단 정보 */} + {attachments.length > 0 && !isLoading && ( + <div className="flex justify-between items-center"> + <span> + 총 {attachments.length}개 파일 + {attachments.some(a => a.fileSize) && + ` · 전체 크기: ${formatFileSize( + attachments.reduce((sum, a) => sum + (a.fileSize || 0), 0) + )}` + } + </span> + </div> + )} + + {/* 전체 다운로드 버튼 추가 */} + {attachments.length > 0 && !isLoading && ( + <Button + onClick={handleDownloadAll} + disabled={isDownloadingAll} + variant="outline" + size="sm" + > + {isDownloadingAll ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 다운로드 중... + </> + ) : ( + <> + <Download className="mr-2 h-4 w-4" /> + 전체 다운로드 + </> + )} + </Button> + )} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/rfq-items-dialog.tsx b/lib/rfq-last/vendor-response/rfq-items-dialog.tsx new file mode 100644 index 00000000..daa692e9 --- /dev/null +++ b/lib/rfq-last/vendor-response/rfq-items-dialog.tsx @@ -0,0 +1,354 @@ +"use client" + +import * as React from "react" +import { format } from "date-fns" +import { Package, ExternalLink } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { Separator } from "@/components/ui/separator" +import { toast } from "sonner" +import { RfqsLastView } from "@/db/schema" +import { getRfqItemsAction } from "../service" + +// 품목 타입 +interface RfqItem { + id: number + rfqsLastId: number | null + rfqItem: string | null + prItem: string | null + prNo: string | null + materialCode: string | null + materialCategory: string | null + acc: string | null + materialDescription: string | null + size: string | null + deliveryDate: Date | null + quantity: number | null + uom: string | null + grossWeight: number | null + gwUom: string | null + specNo: string | null + specUrl: string | null + trackingNo: string | null + majorYn: boolean | null + remark: string | null + projectDef: string | null + projectSc: string | null + projectKl: string | null + projectLc: string | null + projectDl: string | null + // RFQ 관련 정보 + rfqCode: string | null + rfqType: string | null + rfqTitle: string | null + itemCode: string | null + itemName: string | null + projectCode: string | null + projectName: string | null +} + +interface ItemStatistics { + total: number + major: number + regular: number + totalQuantity: number + totalWeight: number +} + +interface RfqItemsDialogProps { + isOpen: boolean + onClose: () => void + rfqData: RfqsLastView +} + +export function RfqItemsDialog({ isOpen, onClose, rfqData }: RfqItemsDialogProps) { + const [items, setItems] = React.useState<RfqItem[]>([]) + const [statistics, setStatistics] = React.useState<ItemStatistics | null>(null) + const [isLoading, setIsLoading] = React.useState(false) + + // 품목 목록 로드 + React.useEffect(() => { + if (!isOpen || !rfqData.id) return + + const loadItems = async () => { + setIsLoading(true) + try { + const result = await getRfqItemsAction(rfqData.id) + + if (result.success) { + setItems(result.data) + setStatistics(result.statistics) + } else { + toast.error(result.error || "품목을 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } + } catch (error) { + console.error("품목 로드 오류:", error) + toast.error("품목을 불러오는데 실패했습니다") + setItems([]) + setStatistics(null) + } finally { + setIsLoading(false) + } + } + + loadItems() + }, [isOpen, rfqData.id]) + + // 사양서 링크 열기 + const handleOpenSpec = (specUrl: string) => { + 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}> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>견적 품목 목록</DialogTitle> + <DialogDescription> + {rfqData.rfqCode} - {rfqData.rfqTitle || rfqData.itemName || "품목 정보"} + </DialogDescription> + </DialogHeader> + + {/* 통계 정보 */} + {statistics && !isLoading && ( + <> +<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3 py-3"> +<div className="text-center"> + <div className="text-2xl font-bold text-primary">{statistics.total}</div> + <div className="text-xs text-muted-foreground">전체 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-blue-600">{statistics.major}</div> + <div className="text-xs text-muted-foreground">주요 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-gray-600">{statistics.regular}</div> + <div className="text-xs text-muted-foreground">일반 품목</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-green-600">{statistics.totalQuantity.toLocaleString()}</div> + <div className="text-xs text-muted-foreground">총 수량</div> + </div> + <div className="text-center"> + <div className="text-2xl font-bold text-orange-600">{statistics.totalWeight.toLocaleString()}</div> + <div className="text-xs text-muted-foreground">총 중량 (KG)</div> + </div> + </div> + <Separator /> + </> + )} + + <ScrollArea className="flex-1"> + {isLoading ? ( + <Table> + <TableHeader> + <TableRow> + <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-[100px]">납기일</TableHead> + <TableHead className="w-[100px]">PR번호</TableHead> + <TableHead className="w-[80px]">사양</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {[...Array(3)].map((_, i) => ( + <TableRow key={i}> + <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> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + <TableCell><Skeleton className="h-8 w-full" /></TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) : items.length === 0 ? ( + <div className="text-center text-muted-foreground py-12"> + <Package className="h-12 w-12 mx-auto mb-4 text-muted-foreground" /> + <p>품목이 없습니다.</p> + </div> + ) : ( + <Table> + <TableHeader> + <TableRow> + <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-[100px]">납기일</TableHead> + <TableHead className="w-[100px]">PR번호</TableHead> + <TableHead className="w-[100px]">사양</TableHead> + <TableHead className="w-[100px]">프로젝트</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {items.map((item, index) => ( + <TableRow key={item.id} className={item.majorYn ? "bg-blue-50 border-l-4 border-l-blue-500" : ""}> + <TableCell> + <div className="flex flex-col items-center gap-1"> + <span className="text-xs font-mono">#{index + 1}</span> + {item.majorYn && ( + <Badge variant="default" className="text-xs px-1 py-0"> + 주요 + </Badge> + )} + </div> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="font-mono text-sm font-medium">{item.materialCode || "-"}</span> + {item.acc && ( + <span className="text-xs text-muted-foreground font-mono"> + ACC: {item.acc} + </span> + )} + </div> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-sm font-medium" title={item.materialDescription || ""}> + {item.materialDescription || "-"} + </span> + {item.materialCategory && ( + <span className="text-xs text-muted-foreground"> + {item.materialCategory} + </span> + )} + {item.size && ( + <span className="text-xs text-muted-foreground"> + 크기: {item.size} + </span> + )} + </div> + </TableCell> + <TableCell> + <span className="text-sm font-medium"> + {formatQuantity(item.quantity, item.uom)} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {formatWeight(item.grossWeight, item.gwUom)} + </span> + </TableCell> + <TableCell> + <span className="text-sm"> + {item.deliveryDate ? format(new Date(item.deliveryDate), "yyyy-MM-dd") : "-"} + </span> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-xs font-mono">{item.prNo || "-"}</span> + {item.prItem && item.prItem !== item.prNo && ( + <span className="text-xs text-muted-foreground font-mono"> + {item.prItem} + </span> + )} + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + {item.specNo && ( + <span className="text-xs font-mono">{item.specNo}</span> + )} + {item.specUrl && ( + <Button + variant="ghost" + size="sm" + className="h-5 w-5 p-0" + onClick={() => handleOpenSpec(item.specUrl!)} + title="사양서 열기" + > + <ExternalLink className="h-3 w-3" /> + </Button> + )} + {item.trackingNo && ( + <div className="text-xs text-muted-foreground"> + TRK: {item.trackingNo} + </div> + )} + </div> + </TableCell> + <TableCell> + <div className="text-xs"> + {[ + item.projectDef && `DEF: ${item.projectDef}`, + item.projectSc && `SC: ${item.projectSc}`, + item.projectKl && `KL: ${item.projectKl}`, + item.projectLc && `LC: ${item.projectLc}`, + item.projectDl && `DL: ${item.projectDl}` + ].filter(Boolean).join(" | ") || "-"} + </div> + </TableCell> + <TableCell> + <span className="text-xs" title={item.remark || ""}> + {item.remark ? (item.remark.length > 30 ? `${item.remark.slice(0, 30)}...` : item.remark) : "-"} + </span> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </ScrollArea> + + {/* 하단 통계 정보 */} + {statistics && !isLoading && ( + <div className="border-t pt-3 text-xs text-muted-foreground"> + <div className="flex justify-between items-center"> + <span> + 총 {statistics.total}개 품목 + (주요: {statistics.major}개, 일반: {statistics.regular}개) + </span> + <span> + 전체 수량: {statistics.totalQuantity.toLocaleString()} | + 전체 중량: {statistics.totalWeight.toLocaleString()} KG + </span> + </div> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/service.ts b/lib/rfq-last/vendor-response/service.ts new file mode 100644 index 00000000..7de3ae58 --- /dev/null +++ b/lib/rfq-last/vendor-response/service.ts @@ -0,0 +1,483 @@ +// getVendorQuotationsLast.ts +'use server' + +import { revalidatePath, unstable_cache } from "next/cache"; +import db from "@/db/db"; +import { and, or, eq, desc, asc, count, ilike, inArray } from "drizzle-orm"; +import { + rfqsLastView, + rfqLastDetails, + rfqLastVendorResponses, + type RfqsLastView +} from "@/db/schema"; +import { filterColumns } from "@/lib/filter-columns"; +import type { GetQuotationsLastSchema, UpdateParticipationSchema } from "@/lib/rfq-last/vendor-response/validations"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { getRfqAttachmentsAction } from "../service"; + + +export type VendorQuotationStatus = + | "미응답" // 초대받았지만 참여 여부 미결정 + | "불참" // 참여 거절 + | "작성중" // 참여 후 작성중 + | "제출완료" // 견적서 제출 완료 + | "수정요청" // 구매자가 수정 요청 + | "최종확정" // 최종 확정됨 + | "취소" // 취소됨 + +// 벤더 견적 뷰 타입 확장 +export interface VendorQuotationView extends RfqsLastView { + // 벤더 응답 정보 + responseStatus?: VendorQuotationStatus; + displayStatus?:string; + responseVersion?: number; + submittedAt?: Date; + totalAmount?: number; + vendorCurrency?: string; + + // 벤더별 조건 + vendorPaymentTerms?: string; + vendorIncoterms?: string; + vendorDeliveryDate?: Date; + + participationStatus: "미응답" | "참여" | "불참" | null + participationRepliedAt: Date | null + nonParticipationReason: string | null +} + +/** + * 벤더별 RFQ 목록 조회 + */ +export async function getVendorQuotationsLast( + input: GetQuotationsLastSchema, + vendorId: string +) { + return unstable_cache( + async () => { + try { + const numericVendorId = parseInt(vendorId); + if (isNaN(numericVendorId)) { + return { data: [], pageCount: 0 }; + } + + // 페이지네이션 설정 + const page = input.page || 1; + const perPage = input.perPage || 10; + const offset = (page - 1) * perPage; + + // 1. 먼저 벤더가 포함된 RFQ ID들 조회 + const vendorRfqIds = await db + .select({ rfqsLastId: rfqLastDetails.rfqsLastId }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.vendorsId, numericVendorId), + eq(rfqLastDetails.isLatest, true) + ) + ); + + + const rfqIds = vendorRfqIds.map(r => r.rfqsLastId).filter(id => id !== null); + + if (rfqIds.length === 0) { + return { data: [], pageCount: 0 }; + } + + // 2. 필터링 설정 + // advancedTable 모드로 where 절 구성 + const advancedWhere = filterColumns({ + table: rfqsLastView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 글로벌 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(rfqsLastView.rfqCode, s), + ilike(rfqsLastView.rfqTitle, s), + ilike(rfqsLastView.itemName, s), + ilike(rfqsLastView.projectName, s), + ilike(rfqsLastView.packageName, s), + ilike(rfqsLastView.status, s) + ); + } + + // RFQ ID 조건 (벤더가 포함된 RFQ만) + const rfqIdWhere = inArray(rfqsLastView.id, rfqIds); + + // 모든 조건 결합 + let whereConditions = [rfqIdWhere]; // 필수 조건 + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); + + // 최종 조건 + const finalWhere = and(...whereConditions); + + // 3. 정렬 설정 + const orderBy = input.sort && input.sort.length > 0 + ? input.sort.map((item) => { + // @ts-ignore - 동적 속성 접근 + return item.desc ? desc(rfqsLastView[item.id]) : asc(rfqsLastView[item.id]); + }) + : [desc(rfqsLastView.updatedAt)]; + + // 4. 메인 쿼리 실행 + const quotations = await db + .select() + .from(rfqsLastView) + .where(finalWhere) + .orderBy(...orderBy) + .limit(perPage) + .offset(offset); + + // 5. 각 RFQ에 대한 벤더 응답 정보 조회 + const quotationsWithResponse = await Promise.all( + quotations.map(async (rfq) => { + // 벤더 응답 정보 조회 + const response = await db.query.rfqLastVendorResponses.findFirst({ + where: and( + eq(rfqLastVendorResponses.rfqsLastId, rfq.id), + eq(rfqLastVendorResponses.vendorId, numericVendorId), + eq(rfqLastVendorResponses.isLatest, true) + ), + columns: { + status: true, + responseVersion: true, + submittedAt: true, + totalAmount: true, + vendorCurrency: true, + vendorPaymentTermsCode: true, + vendorIncotermsCode: true, + vendorDeliveryDate: true, + participationStatus: true, + participationRepliedAt: true, + nonParticipationReason: true, + } + }); + + // 벤더 상세 정보 조회 + const detail = await db.query.rfqLastDetails.findFirst({ + where: and( + eq(rfqLastDetails.rfqsLastId, rfq.id), + eq(rfqLastDetails.vendorsId, numericVendorId), + eq(rfqLastDetails.isLatest, true) + ), + columns: { + id: true, // rfqLastDetailsId 필요 + emailSentAt: true, + emailStatus: true, + shortList: true, + } + }); + + // 표시할 상태 결정 (새로운 로직) + let displayStatus: string | null = null; + + if (response) { + // 응답 레코드가 있는 경우 + if (response.participationStatus === "불참") { + displayStatus = "불참"; + } else if (response.participationStatus === "참여") { + // 참여한 경우 실제 작업 상태 표시 + displayStatus = response.status || "작성중"; + } else { + // participationStatus가 없거나 "미응답"인 경우 + displayStatus = "미응답"; + } + } else { + // 응답 레코드가 없는 경우 + if (detail?.emailSentAt) { + displayStatus = "미응답"; // 초대는 받았지만 응답 안함 + } else { + displayStatus = null; // 아직 초대도 안됨 + } + } + + return { + ...rfq, + // 새로운 상태 체계 + displayStatus, // UI에서 표시할 통합 상태 + + // 참여 관련 정보 + participationStatus: response?.participationStatus || "미응답", + participationRepliedAt: response?.participationRepliedAt, + nonParticipationReason: response?.nonParticipationReason, + + // 견적 작업 상태 (참여한 경우에만 의미 있음) + responseStatus: response?.status, + responseVersion: response?.responseVersion, + submittedAt: response?.submittedAt, + totalAmount: response?.totalAmount, + vendorCurrency: response?.vendorCurrency, + vendorPaymentTerms: response?.vendorPaymentTermsCode, + vendorIncoterms: response?.vendorIncotermsCode, + vendorDeliveryDate: response?.vendorDeliveryDate, + + // 초대 관련 정보 + rfqLastDetailsId: detail?.id, // 참여 결정 시 필요 + emailSentAt: detail?.emailSentAt, + emailStatus: detail?.emailStatus, + shortList: detail?.shortList, + } as VendorQuotationView; + }) + ); + + // 6. 전체 개수 조회 + const { totalCount } = await db + .select({ totalCount: count() }) + .from(rfqsLastView) + .where(finalWhere) + .then(rows => rows[0]); + + // 페이지 수 계산 + const pageCount = Math.ceil(Number(totalCount) / perPage); + + + return { + data: quotationsWithResponse, + pageCount + }; + } catch (err) { + console.error("getVendorQuotationsLast 에러:", err); + return { data: [], pageCount: 0 }; + } + }, + [`vendor-quotations-last-${vendorId}-${JSON.stringify(input)}`], + { + revalidate: 60, + tags: [`vendor-quotations-last-${vendorId}`], + } + )(); +} + + + +export async function getQuotationStatusCountsLast(vendorId: string) { + return unstable_cache( + async () => { + try { + const numericVendorId = parseInt(vendorId); + if (isNaN(numericVendorId)) { + return { + "미응답": 0, + "불참": 0, + "작성중": 0, + "제출완료": 0, + "수정요청": 0, + "최종확정": 0, + "취소": 0, + } as Record<VendorQuotationStatus, number>; + } + + // 1. 벤더가 초대받은 전체 RFQ 조회 + const invitedRfqs = await db + .select({ + rfqsLastId: rfqLastDetails.rfqsLastId, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.vendorsId, numericVendorId), + eq(rfqLastDetails.isLatest, true) + ) + ); + + const invitedRfqIds = invitedRfqs.map(r => r.rfqsLastId); + const totalInvited = invitedRfqIds.length; + + // 초대받은 RFQ가 없으면 모두 0 반환 + if (totalInvited === 0) { + return { + "미응답": 0, + "불참": 0, + "작성중": 0, + "제출완료": 0, + "수정요청": 0, + "최종확정": 0, + "취소": 0, + } as Record<VendorQuotationStatus, number>; + } + + // 2. 벤더의 응답 상태 조회 + const vendorResponses = await db + .select({ + participationStatus: rfqLastVendorResponses.participationStatus, + status: rfqLastVendorResponses.status, + rfqsLastId: rfqLastVendorResponses.rfqsLastId, + }) + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.vendorId, numericVendorId), + eq(rfqLastVendorResponses.isLatest, true), + inArray(rfqLastVendorResponses.rfqsLastId, invitedRfqIds) + ) + ); + + // 3. 상태별 카운트 계산 + const result: Record<VendorQuotationStatus, number> = { + "미응답": 0, + "불참": 0, + "작성중": 0, + "제출완료": 0, + "수정요청": 0, + "최종확정": 0, + "취소": 0, + }; + + // 응답이 있는 RFQ ID 세트 + const respondedRfqIds = new Set(vendorResponses.map(r => r.rfqsLastId)); + + // 미응답 = 초대받았지만 응답 레코드가 없거나 participationStatus가 미응답인 경우 + result["미응답"] = totalInvited - respondedRfqIds.size; + + // 응답별 상태 카운트 + vendorResponses.forEach(response => { + // 불참한 경우 + if (response.participationStatus === "불참") { + result["불참"]++; + } + // 참여했지만 아직 participationStatus가 없는 경우 (기존 데이터 호환성) + else if (!response.participationStatus || response.participationStatus === "미응답") { + // 응답 레코드는 있지만 참여 여부 미결정 + result["미응답"]++; + } + // 참여한 경우 - status에 따라 분류 + else if (response.participationStatus === "참여") { + switch (response.status) { + case "대기중": + case "작성중": + result["작성중"]++; + break; + case "제출완료": + result["제출완료"]++; + break; + case "수정요청": + result["수정요청"]++; + break; + case "최종확정": + result["최종확정"]++; + break; + case "취소": + result["취소"]++; + break; + default: + // 기존 상태 호환성 처리 + if (response.status === "초대됨") { + result["미응답"]++; + } else if (response.status === "제출완료" || response.status === "Submitted") { + result["제출완료"]++; + } + break; + } + } + }); + + return result; + } catch (err) { + console.error("getQuotationStatusCountsLast 에러:", err); + return { + "미응답": 0, + "불참": 0, + "작성중": 0, + "제출완료": 0, + "수정요청": 0, + "최종확정": 0, + "취소": 0, + } as Record<VendorQuotationStatus, number>; + } + }, + [`quotation-status-counts-last-${vendorId}`], + { + revalidate: 60, + tags: [`quotation-status-last-${vendorId}`], + } + )(); +} + +export async function updateParticipationStatus( + input: UpdateParticipationSchema +) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const vendorId = session.user.companyId; + const { rfqId, rfqLastDetailsId, participationStatus, nonParticipationReason } = input + + // 기존 응답 레코드 찾기 또는 생성 + const existingResponse = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, Number(vendorId)), + eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetailsId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1) + + + const now = new Date() + const userId = parseInt(session.user.id) + + if (existingResponse.length > 0) { + // 기존 레코드 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + participationStatus, + participationRepliedAt: now, + participationRepliedBy: userId, + nonParticipationReason: participationStatus === "불참" ? nonParticipationReason : null, + status: participationStatus === "참여" ? "작성중" : "대기중", + updatedAt: now, + updatedBy:userId, + }) + .where(eq(rfqLastVendorResponses.id, existingResponse[0].id)) + + } + + // revalidatePath("/vendor/quotations") + + return { + success: true, + message: participationStatus === "참여" + ? "견적 참여가 확정되었습니다." + : "견적 불참이 처리되었습니다." + } + } catch (error) { + console.error("참여 여부 업데이트 에러:", error) + return { + success: false, + message: "참여 여부 업데이트 중 오류가 발생했습니다." + } + } +} + + + +interface UpdateVendorContractRequirementsParams { + rfqId: number; + detailId: number; + contractRequirements: { + agreementYn: boolean; + ndaYn: boolean; + gtcType: "general" | "project" | "none"; + }; +} + +interface UpdateResult { + success: boolean; + error?: string; + data?: any; +} + diff --git a/lib/rfq-last/vendor-response/validations.ts b/lib/rfq-last/vendor-response/validations.ts new file mode 100644 index 00000000..033154c2 --- /dev/null +++ b/lib/rfq-last/vendor-response/validations.ts @@ -0,0 +1,42 @@ +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum,parseAsBoolean +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { RfqsLastView } from "@/db/schema"; + + + +export const searchParamsVendorRfqCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<RfqsLastView>().withDefault([ + { id: "updatedAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + search: parseAsString.withDefault(""), + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), +}); + +export type GetQuotationsLastSchema = Awaited<ReturnType<typeof searchParamsVendorRfqCache.parse>>; + +// 참여 여부 업데이트 스키마 +export const updateParticipationSchema = z.object({ + rfqId: z.number(), + rfqLastDetailsId: z.number(), + participationStatus: z.enum(["참여", "불참"]), + nonParticipationReason: z.string().optional(), +}) + + +export type UpdateParticipationSchema = z.infer<typeof updateParticipationSchema>; diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx new file mode 100644 index 00000000..144c6c43 --- /dev/null +++ b/lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx @@ -0,0 +1,514 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { + FileText, + Edit, + Send, + Eye, + Clock, + CheckCircle, + AlertCircle, + XCircle, + Mail, + UserX +} from "lucide-react" +import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" +import type { VendorQuotationView } from "./service" +import { ParticipationDialog } from "./participation-dialog" + +// 통합 상태 배지 컴포넌트 (displayStatus 사용) +function DisplayStatusBadge({ status }: { status: string | null }) { + if (!status) return null + + const config = { + "미응답": { variant: "secondary" as const, icon: Mail, label: "응답 대기" }, + "불참": { variant: "destructive" as const, icon: UserX, label: "불참" }, + "작성중": { variant: "outline" as const, icon: Edit, label: "작성중" }, + "제출완료": { variant: "default" as const, icon: CheckCircle, label: "제출완료" }, + "수정요청": { variant: "warning" as const, icon: AlertCircle, label: "수정요청" }, + "최종확정": { variant: "success" as const, icon: CheckCircle, label: "최종확정" }, + "취소": { variant: "destructive" as const, icon: XCircle, label: "취소" }, + } + + const { variant, icon: Icon, label } = config[status as keyof typeof config] || { + variant: "outline" as const, + icon: Clock, + label: status + } + + return ( + <Badge variant={variant} className="gap-1"> + <Icon className="h-3 w-3" /> + {label} + </Badge> + ) +} + +// RFQ 상태 배지 (기존 유지) +function RfqStatusBadge({ status }: { status: string }) { + const config: Record<string, { variant: "default" | "secondary" | "outline" | "destructive" | "warning" | "success" }> = { + "RFQ 생성": { variant: "outline" }, + "구매담당지정": { variant: "secondary" }, + "견적요청문서 확정": { variant: "secondary" }, + "TBE 완료": { variant: "warning" }, + "RFQ 발송": { variant: "default" }, + "견적접수": { variant: "success" }, + "최종업체선정": { variant: "success" }, + } + + const { variant } = config[status] || { variant: "outline" as const } + return <Badge variant={variant}>{status}</Badge> +} + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorQuotationView> | null>>; + router: NextRouter; + vendorId: number; // 추가: 벤더 ID 전달 +} + +export function getColumns({ + setRowAction, + router, + vendorId, // 추가 +}: GetColumnsProps): ColumnDef<VendorQuotationView>[] { + + // 체크박스 컬럼 (기존 유지) + const selectColumn: ColumnDef<VendorQuotationView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // 액션 컬럼 + const actionsColumn: ColumnDef<VendorQuotationView> = { + id: "actions", + header: "작업", + enableHiding: false, + cell: ({ row }) => { + const rfqId = row.original.id + const rfqCode = row.original.rfqCode + const displayStatus = row.original.displayStatus + const rfqLastDetailsId = row.original.rfqLastDetailsId + const [showParticipationDialog, setShowParticipationDialog] = React.useState(false) + + // displayStatus 기반으로 액션 결정 + switch (displayStatus) { + case "미응답": + return ( + <> + <Button + variant="default" + size="sm" + onClick={() => setShowParticipationDialog(true)} + className="h-8" + > + <Mail className="h-4 w-4 mr-1" /> + 참여 여부 결정 + </Button> + {showParticipationDialog && ( + <ParticipationDialog + rfqId={rfqId} + rfqCode={rfqCode} + rfqLastDetailsId={rfqLastDetailsId} + currentStatus={displayStatus} + onClose={() => setShowParticipationDialog(false)} + /> + )} + </> + ) + + case "불참": + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm text-muted-foreground">불참</span> + </TooltipTrigger> + {row.original.nonParticipationReason && ( + <TooltipContent> + <p className="max-w-xs"> + 불참 사유: {row.original.nonParticipationReason} + </p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ) + + case "작성중": + case "대기중": + return ( + <Button + variant="default" + size="sm" + onClick={() => router.push(`/partners/rfq-last/${rfqId}`)} + className="h-8" + > + <Edit className="h-4 w-4 mr-1" /> + 견적서 작성 + </Button> + ) + + case "수정요청": + return ( + <Button + variant="warning" + size="sm" + onClick={() => router.push(`/partners/rfq-last/${rfqId}`)} + className="h-8" + > + <AlertCircle className="h-4 w-4 mr-1" /> + 견적서 수정 + </Button> + ) + + case "제출완료": + case "최종확정": + return ( + <Button + variant="outline" + size="sm" + onClick={() => router.push(`/partners/rfq-last/${rfqId}`)} + className="h-8" + > + <Eye className="h-4 w-4 mr-1" /> + 견적서 보기 + </Button> + ) + + case "취소": + return ( + <span className="text-sm text-muted-foreground">취소됨</span> + ) + + default: + return null + } + }, + size: 150, + } + + // 기본 컬럼들 + const columns: ColumnDef<VendorQuotationView>[] = [ + selectColumn, + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 번호" /> + ), + cell: ({ row }) => { + const value = row.getValue("rfqCode") + return ( + <span className="font-mono text-sm font-medium"> + {value || "-"} + </span> + ) + }, + size: 140, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + { + accessorKey: "rfqType", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 유형" /> + ), + cell: ({ row }) => { + const rfqCode = row.original.rfqCode + const value = row.getValue("rfqType") + + // F로 시작하지 않으면 빈 값 반환 + if (!rfqCode?.startsWith('F')) { + return null + } + + const typeMap: Record<string, string> = { + "ITB": "ITB", + "RFQ": "RFQ", + "일반견적": "일반견적" + } + return typeMap[value as string] || value || "-" + }, + size: 100, + minSize: 80, + maxSize: 120, + enableResizing: true, + // F로 시작하지 않을 때 컬럼 숨기기 + enableHiding: true, + }, + { + accessorKey: "rfqTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 제목" /> + ), + cell: ({ row }) => { + const rfqCode = row.original.rfqCode + const value = row.getValue("rfqTitle") + + // F로 시작하지 않으면 빈 값 반환 + if (!rfqCode?.startsWith('F')) { + return null + } + + return value || "-" + }, + minSize: 200, + maxSize: 400, + enableResizing: true, + enableHiding: true, + }, + { + accessorKey: "projectName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + <span className="max-w-[200px] truncate" title={row.original.projectName || ""}> + {row.original.projectName || "-"} + </span> + </div> + ), + minSize: 150, + maxSize: 300, + enableResizing: true, + }, + { + accessorKey: "itemName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="품목명" /> + ), + cell: ({ row }) => row.getValue("itemName") || "-", + minSize: 150, + maxSize: 300, + enableResizing: true, + }, + { + accessorKey: "packageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="패키지" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.packageNo} + </span> + <span className="max-w-[200px] truncate" title={row.original.packageName || ""}> + {row.original.packageName || "-"} + </span> + </div> + ), + minSize: 120, + maxSize: 250, + enableResizing: true, + }, + { + accessorKey: "MaterialGroup", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="자재그룹" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-mono text-xs text-muted-foreground"> + {row.original.majorItemMaterialCategory} + </span> + <span className="max-w-[200px] truncate" title={row.original.majorItemMaterialDescription || ""}> + {row.original.majorItemMaterialDescription || "-"} + </span> + </div> + ), + minSize: 120, + maxSize: 250, + enableResizing: true, + }, + + { + id: "rfqDocument", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 자료" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + onClick={() => setRowAction({ row, type: "attachment" })} + > + <FileText className="h-4 w-4" /> + </Button> + ), + size: 80, + }, + // 견적품목수 - 수정됨 + { + accessorKey: "prItemsCount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적품목수" />, + cell: ({ row }) => ( + <Button + variant="ghost" + size="sm" + className="font-mono text-sm p-1 h-auto" + onClick={() => setRowAction({ row, type: "items" })} + > + {row.original.prItemsCount || 0} + </Button> + ), + size: 90, + }, + + { + accessorKey: "engPicName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설계담당자" />, + cell: ({ row }) => row.original.engPicName || "-", + size: 100, + }, + + { + accessorKey: "picUserName", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, + cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + size: 100, + }, + + { + accessorKey: "submittedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="제출일" /> + ), + cell: ({ row }) => { + return row.original.submittedAt + ? formatDateTime(new Date(row.original.submittedAt)) + : "-" + }, + size: 150, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + { + accessorKey: "totalAmount", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="견적 금액" /> + ), + cell: ({ row }) => { + if (!row.original.totalAmount) return "-" + return formatCurrency( + row.original.totalAmount, + row.original.vendorCurrency || "USD" + ) + }, + size: 140, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + { + accessorKey: "displayStatus", // 변경: responseStatus → displayStatus + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => <DisplayStatusBadge status={row.original.displayStatus} />, + size: 120, + minSize: 100, + maxSize: 150, + enableResizing: true, + }, + { + accessorKey: "rfqSendDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="발송일" /> + ), + cell: ({ row }) => { + const value = row.getValue("rfqSendDate") + return value ? formatDateTime(new Date(value as string)) : "-" + }, + size: 150, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + { + accessorKey: "participationRepliedAt", // 추가: 참여 응답일 + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="참여 응답일" /> + ), + cell: ({ row }) => { + const value = row.getValue("participationRepliedAt") + return value ? formatDateTime(new Date(value as string)) : "-" + }, + size: 150, + minSize: 120, + maxSize: 180, + enableResizing: true, + enableHiding: true, // 선택적 표시 + }, + { + accessorKey: "dueDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="마감일" /> + ), + cell: ({ row }) => { + const value = row.getValue("dueDate") + const now = new Date() + const dueDate = value ? new Date(value as string) : null + const isOverdue = dueDate && dueDate < now + const isNearDeadline = dueDate && + (dueDate.getTime() - now.getTime()) < (24 * 60 * 60 * 1000) // 24시간 이내 + + return ( + <span className={ + isOverdue ? "text-red-600 font-semibold" : + isNearDeadline ? "text-orange-600 font-semibold" : + "" + }> + {dueDate ? formatDateTime(dueDate) : "-"} + </span> + ) + }, + size: 150, + minSize: 120, + maxSize: 180, + enableResizing: true, + }, + actionsColumn, + ] + + return columns +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor-response/vendor-quotations-table.tsx b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx new file mode 100644 index 00000000..683a0318 --- /dev/null +++ b/lib/rfq-last/vendor-response/vendor-quotations-table.tsx @@ -0,0 +1,171 @@ +// vendor-quotations-table-last.tsx +"use client" + +import * as React from "react" +import { + type DataTableAdvancedFilterField, + type DataTableFilterField, + type DataTableRowAction +} from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useRouter } from "next/navigation" +import { getColumns } from "./vendor-quotations-table-columns" +import type { VendorQuotationView } from "./service" +import { RfqAttachmentsDialog } from "./rfq-attachments-dialog"; +import { RfqItemsDialog } from "./rfq-items-dialog"; + +interface VendorQuotationsTableLastProps { + promises: Promise<[{ data: VendorQuotationView[], pageCount: number }]> +} + +export function VendorQuotationsTableLast({ promises }: VendorQuotationsTableLastProps) { + const [{ data, pageCount }] = React.use(promises) + const router = useRouter() + + console.log(data,"VendorQuotationsTableLast") + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorQuotationView> | null>(null) + + // 테이블 컬럼 + const columns = React.useMemo(() => getColumns({ + setRowAction, + router, + }), [setRowAction, router]) + + // 응답 상태별 카운트 + const statusCounts = React.useMemo(() => { + return { + notResponded: data.filter(q => q.displayStatus === "미응답").length, + declined: data.filter(q => q.displayStatus === "불참").length, + drafting: data.filter(q => q.displayStatus === "작성중").length, + submitted: data.filter(q => q.displayStatus === "제출완료").length, + revisionRequested: data.filter(q => q.displayStatus === "수정요청").length, + confirmed: data.filter(q => q.displayStatus === "최종확정").length, + cancelled: data.filter(q => q.displayStatus === "취소").length, + } + }, [data]) + + // 필터 필드 + const filterFields: DataTableFilterField<VendorQuotationView>[] = [ + { + id: "displayStatus", + label: "상태", + options: [ + { label: "미응답", value: "미응답", count: statusCounts.notResponded }, + { label: "불참", value: "불참", count: statusCounts.declined }, + { label: "작성중", value: "작성중", count: statusCounts.drafting }, + { label: "제출완료", value: "제출완료", count: statusCounts.submitted }, + { label: "수정요청", value: "수정요청", count: statusCounts.revisionRequested }, + { label: "최종확정", value: "최종확정", count: statusCounts.confirmed }, + { label: "취소", value: "취소", count: statusCounts.cancelled }, + ] + }, + { + id: "rfqCode", + label: "RFQ 번호", + placeholder: "RFQ 번호 검색...", + }, + ] + + // 고급 필터 필드 + const advancedFilterFields: DataTableAdvancedFilterField<VendorQuotationView>[] = [ + { + id: "rfqCode", + label: "RFQ 번호", + type: "text", + }, + { + id: "rfqTitle", + label: "RFQ 제목", + type: "text", + }, + { + id: "projectName", + label: "프로젝트명", + type: "text", + }, + { + id: "displayStatus", + label: "상태", + options: [ + { label: "미응답", value: "미응답", count: statusCounts.notResponded }, + { label: "불참", value: "불참", count: statusCounts.declined }, + { label: "작성중", value: "작성중", count: statusCounts.drafting }, + { label: "제출완료", value: "제출완료", count: statusCounts.submitted }, + { label: "수정요청", value: "수정요청", count: statusCounts.revisionRequested }, + { label: "최종확정", value: "최종확정", count: statusCounts.confirmed }, + { label: "취소", value: "취소", count: statusCounts.cancelled }, + ] + }, + { + id: "dueDate", + label: "마감일", + type: "date", + }, + { + id: "submittedAt", + label: "제출일", + type: "date", + }, + ] + + // useDataTable 훅 사용 + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + enableColumnResizing: true, + columnResizeMode: 'onChange', + initialState: { + sorting: [{ id: "updatedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + defaultColumn: { + minSize: 50, + maxSize: 500, + }, + }) + + return ( + // <div className="w-full"> + <> + <div className="overflow-x-auto"> + <DataTable + table={table} + className="min-w-full" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + </div> + {/* 다이얼로그들 */} + {rowAction?.type === "attachment" && ( + <RfqAttachmentsDialog + isOpen={true} + onClose={() => setRowAction(null)} + rfqData={rowAction.row.original} + /> + )} + + {rowAction?.type === "items" && ( + <RfqItemsDialog + isOpen={true} + onClose={() => setRowAction(null)} + rfqData={rowAction.row.original} + /> + )} + </> + // </div> + ) +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/add-vendor-dialog.tsx b/lib/rfq-last/vendor/add-vendor-dialog.tsx index d8745298..8566763f 100644 --- a/lib/rfq-last/vendor/add-vendor-dialog.tsx +++ b/lib/rfq-last/vendor/add-vendor-dialog.tsx @@ -24,7 +24,7 @@ import { PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Check, ChevronsUpDown, Loader2, X, Plus } from "lucide-react"; +import { Check, ChevronsUpDown, Loader2, X, Plus, FileText, Shield, Globe, Settings } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { addVendorsToRfq } from "../service"; @@ -34,6 +34,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Info } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Separator } from "@/components/ui/separator"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface VendorContract { + vendorId: number; + agreementYn: boolean; + ndaYn: boolean; + gtcType: "general" | "project" | "none"; +} interface AddVendorDialogProps { open: boolean; @@ -52,6 +63,17 @@ export function AddVendorDialog({ const [vendorOpen, setVendorOpen] = React.useState(false); const [vendorList, setVendorList] = React.useState<any[]>([]); const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]); + const [activeTab, setActiveTab] = React.useState<"vendors" | "contracts">("vendors"); + + // 각 벤더별 기본계약 요구사항 상태 + const [vendorContracts, setVendorContracts] = React.useState<VendorContract[]>([]); + + // 일괄 적용용 기본값 + const [defaultContract, setDefaultContract] = React.useState({ + agreementYn: true, + ndaYn: true, + gtcType: "none" as "general" | "project" | "none" + }); // 벤더 로드 const loadVendors = React.useCallback(async () => { @@ -76,13 +98,33 @@ export function AddVendorDialog({ React.useEffect(() => { if (!open) { setSelectedVendors([]); + setVendorContracts([]); + setActiveTab("vendors"); + setDefaultContract({ + agreementYn: true, + ndaYn: true, + gtcType: "none" + }); } }, [open]); // 벤더 추가 const handleAddVendor = (vendor: any) => { if (!selectedVendors.find(v => v.id === vendor.id)) { - setSelectedVendors([...selectedVendors, vendor]); + const updatedVendors = [...selectedVendors, vendor]; + setSelectedVendors(updatedVendors); + + // 해당 벤더의 기본계약 설정 추가 + const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + setVendorContracts([ + ...vendorContracts, + { + vendorId: vendor.id, + agreementYn: defaultContract.agreementYn, + ndaYn: defaultContract.ndaYn, + gtcType: isInternational ? defaultContract.gtcType : "none" + } + ]); } setVendorOpen(false); }; @@ -90,9 +132,36 @@ export function AddVendorDialog({ // 벤더 제거 const handleRemoveVendor = (vendorId: number) => { setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)); + setVendorContracts(vendorContracts.filter(c => c.vendorId !== vendorId)); }; - // 제출 처리 - 벤더만 추가 + // 개별 벤더의 계약 설정 업데이트 + const updateVendorContract = (vendorId: number, field: string, value: any) => { + setVendorContracts(contracts => + contracts.map(c => + c.vendorId === vendorId ? { ...c, [field]: value } : c + ) + ); + }; + + // 모든 벤더에 일괄 적용 + const applyToAll = () => { + setVendorContracts(contracts => + contracts.map(c => { + const vendor = selectedVendors.find(v => v.id === c.vendorId); + const isInternational = vendor?.country && vendor.country !== "KR" && vendor.country !== "한국"; + return { + ...c, + agreementYn: defaultContract.agreementYn, + ndaYn: defaultContract.ndaYn, + gtcType: isInternational ? defaultContract.gtcType : "none" + }; + }) + ); + toast.success("모든 벤더에 기본계약 설정이 적용되었습니다."); + }; + + // 제출 처리 const handleSubmit = async () => { if (selectedVendors.length === 0) { toast.error("최소 1개 이상의 벤더를 선택해주세요."); @@ -102,18 +171,32 @@ export function AddVendorDialog({ setIsLoading(true); try { - const vendorIds = selectedVendors.map(v => v.id); - const result = await addVendorsToRfq({ - rfqId, - vendorIds, - // 기본값으로 벤더만 추가 (상세 조건은 나중에 일괄 입력) - conditions: null, - }); + // 각 벤더별로 개별 추가 + const results = await Promise.all( + selectedVendors.map(async (vendor) => { + const contract = vendorContracts.find(c => c.vendorId === vendor.id); + return addVendorsToRfq({ + rfqId, + vendorIds: [vendor.id], + conditions: null, + contractRequirements: contract || defaultContract + }); + }) + ); + + // 결과 확인 + const successCount = results.filter(r => r.success).length; + const failedCount = results.length - successCount; - if (result.success) { + if (successCount > 0) { toast.success( <div> - <p>{selectedVendors.length}개 벤더가 추가되었습니다.</p> + <p>{successCount}개 벤더가 추가되었습니다.</p> + {failedCount > 0 && ( + <p className="text-sm text-destructive mt-1"> + {failedCount}개 벤더 추가 실패 + </p> + )} <p className="text-sm text-muted-foreground mt-1"> 벤더 목록에서 '정보 일괄 입력' 버튼으로 조건을 설정하세요. </p> @@ -122,7 +205,7 @@ export function AddVendorDialog({ onSuccess(); onOpenChange(false); } else { - toast.error(result.error || "벤더 추가에 실패했습니다."); + toast.error("벤더 추가에 실패했습니다."); } } catch (error) { console.error("Submit error:", error); @@ -137,38 +220,41 @@ export function AddVendorDialog({ return selectedVendors.some(v => v.id === vendorId); }; + // 선택된 벤더가 있고 계약 탭으로 이동 가능한지 + const canProceedToContracts = selectedVendors.length > 0; + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-2xl max-h-[80vh] p-0 flex flex-col"> + <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col"> {/* 헤더 */} <DialogHeader className="p-6 pb-0"> <DialogTitle>벤더 추가</DialogTitle> <DialogDescription> - 견적 요청을 보낼 벤더를 선택하세요. 조건 설정은 추가 후 일괄로 진행할 수 있습니다. + 견적 요청을 보낼 벤더를 선택하고 각 벤더별 기본계약 요구사항을 설정하세요. </DialogDescription> </DialogHeader> - {/* 컨텐츠 영역 */} - <div className="flex-1 px-6 py-4 overflow-y-auto"> - <div className="space-y-4"> - {/* 안내 메시지 */} - <Alert> - <Info className="h-4 w-4" /> - <AlertDescription> - 여기서는 벤더만 선택합니다. 납기일, 결제조건 등의 상세 정보는 벤더 추가 후 - '정보 일괄 입력' 기능으로 한 번에 설정할 수 있습니다. - </AlertDescription> - </Alert> - - {/* 벤더 선택 카드 */} + {/* 탭 */} + <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="flex-1 flex flex-col min-h-0"> + <TabsList className="mx-6 grid w-fit grid-cols-2"> + <TabsTrigger value="vendors"> + 1. 벤더 선택 + {selectedVendors.length > 0 && ( + <Badge variant="secondary" className="ml-2"> + {selectedVendors.length} + </Badge> + )} + </TabsTrigger> + <TabsTrigger value="contracts" disabled={!canProceedToContracts}> + 2. 기본계약 설정 + </TabsTrigger> + </TabsList> + + {/* 벤더 선택 탭 */} + <TabsContent value="vendors" className="flex-1 flex flex-col px-6 py-4 overflow-y-auto min-h-0"> <Card> <CardHeader> - <div className="flex items-center justify-between"> - <CardTitle className="text-lg">벤더 선택</CardTitle> - <Badge variant="outline" className="ml-2"> - {selectedVendors.length}개 선택됨 - </Badge> - </div> + <CardTitle className="text-lg">벤더 선택</CardTitle> <CardDescription> RFQ를 발송할 벤더를 선택하세요. 여러 개 선택 가능합니다. </CardDescription> @@ -196,11 +282,11 @@ export function AddVendorDialog({ <Command> <CommandInput placeholder="벤더명 또는 코드로 검색..." /> <CommandList - onWheel={(e) => { - e.stopPropagation(); // 이벤트 전파 차단 - const target = e.currentTarget; - target.scrollTop += e.deltaY; // 직접 스크롤 처리 - }} + onWheel={(e) => { + e.stopPropagation(); + const target = e.currentTarget; + target.scrollTop += e.deltaY; + }} > <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> <CommandGroup> @@ -218,9 +304,12 @@ export function AddVendorDialog({ </Badge> <span className="truncate">{vendor.vendorName}</span> {vendor.country && ( - <span className="text-xs text-muted-foreground ml-auto"> + <Badge + variant={vendor.country === "KR" || vendor.country === "한국" ? "default" : "secondary"} + className="ml-auto" + > {vendor.country} - </span> + </Badge> )} </div> </CommandItem> @@ -234,41 +323,46 @@ export function AddVendorDialog({ {/* 선택된 벤더 목록 */} {selectedVendors.length > 0 && ( <div className="space-y-2"> - <Label className="text-sm text-muted-foreground">선택된 벤더 목록</Label> - <ScrollArea className="h-[200px] w-full rounded-md border p-4"> - <div className="space-y-2"> - {selectedVendors.map((vendor, index) => ( - <div - key={vendor.id} - className="flex items-center justify-between p-2 rounded-lg bg-secondary/50" - > - <div className="flex items-center gap-2"> - <span className="text-sm text-muted-foreground"> - {index + 1}. - </span> - <Badge variant="outline"> - {vendor.vendorCode} + + <div className="space-y-2"> + {selectedVendors.map((vendor, index) => ( + <div + key={vendor.id} + className="flex items-center justify-between p-2 rounded-lg bg-secondary/50" + > + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + {index + 1}. + </span> + <Badge variant="outline"> + {vendor.vendorCode} + </Badge> + <span className="text-sm font-medium"> + {vendor.vendorName} + </span> + {vendor.country && ( + <Badge + variant={vendor.country === "KR" || vendor.country === "한국" ? "default" : "secondary"} + className="text-xs" + > + {vendor.country} </Badge> - <span className="text-sm font-medium"> - {vendor.vendorName} - </span> - </div> - <Button - variant="ghost" - size="sm" - onClick={() => handleRemoveVendor(vendor.id)} - className="h-8 w-8 p-0" - > - <X className="h-4 w-4" /> - </Button> + )} </div> - ))} - </div> - </ScrollArea> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveVendor(vendor.id)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> </div> )} - {/* 벤더가 없는 경우 메시지 */} {selectedVendors.length === 0 && ( <div className="text-center py-8 text-muted-foreground"> <p className="text-sm">아직 선택된 벤더가 없습니다.</p> @@ -278,8 +372,177 @@ export function AddVendorDialog({ </div> </CardContent> </Card> - </div> - </div> + </TabsContent> + + {/* 기본계약 설정 탭 */} + <TabsContent value="contracts" className="flex-1 flex flex-col px-6 py-4 overflow-hidden min-h-0"> + <div className="flex-1 overflow-y-auto space-y-4 min-h-0"> + {/* 일괄 적용 카드 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base flex items-center gap-2"> + <Settings className="h-4 w-4" /> + 일괄 적용 설정 + </CardTitle> + <CardDescription> + 모든 벤더에 동일한 설정을 적용할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <div className="flex items-center space-x-2"> + <Checkbox + id="default-agreement" + checked={defaultContract.agreementYn} + onCheckedChange={(checked) => + setDefaultContract({ ...defaultContract, agreementYn: !!checked }) + } + /> + <label htmlFor="default-agreement" className="text-sm font-medium"> + 기술자료 제공 동의 + </label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="default-nda" + checked={defaultContract.ndaYn} + onCheckedChange={(checked) => + setDefaultContract({ ...defaultContract, ndaYn: !!checked }) + } + /> + <label htmlFor="default-nda" className="text-sm font-medium"> + 비밀유지 계약 (NDA) + </label> + </div> + </div> + <div className="space-y-2"> + <Label className="text-sm">GTC (국외 업체용)</Label> + <RadioGroup + value={defaultContract.gtcType} + onValueChange={(value: any) => + setDefaultContract({ ...defaultContract, gtcType: value }) + } + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="none" id="default-gtc-none" /> + <label htmlFor="default-gtc-none" className="text-sm">없음</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="general" id="default-gtc-general" /> + <label htmlFor="default-gtc-general" className="text-sm">General GTC</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="project" id="default-gtc-project" /> + <label htmlFor="default-gtc-project" className="text-sm">Project GTC</label> + </div> + </RadioGroup> + </div> + </div> + <Button + variant="secondary" + size="sm" + onClick={applyToAll} + className="w-full" + > + 모든 벤더에 적용 + </Button> + </CardContent> + </Card> + + {/* 개별 벤더 설정 */} + <Card className="flex flex-col min-h-0"> + <CardHeader className="pb-3"> + <CardTitle className="text-base">개별 벤더 기본계약 설정</CardTitle> + <CardDescription> + 각 벤더별로 다른 기본계약을 요구할 수 있습니다. + </CardDescription> + </CardHeader> + <CardContent className="flex-1 min-h-0"> + <ScrollArea className="h-[250px] pr-4"> + <div className="space-y-4"> + {selectedVendors.map((vendor) => { + const contract = vendorContracts.find(c => c.vendorId === vendor.id); + const isInternational = vendor.country && vendor.country !== "KR" && vendor.country !== "한국"; + + return ( + <div key={vendor.id} className="border rounded-lg p-4 space-y-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{vendor.vendorCode}</Badge> + <span className="font-medium">{vendor.vendorName}</span> + <Badge + variant={isInternational ? "secondary" : "default"} + className="text-xs" + > + {vendor.country || "미지정"} + </Badge> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <div className="flex items-center space-x-2"> + <Checkbox + checked={contract?.agreementYn || false} + onCheckedChange={(checked) => + updateVendorContract(vendor.id, "agreementYn", !!checked) + } + /> + <label className="text-sm">기술자료 제공</label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + checked={contract?.ndaYn || false} + onCheckedChange={(checked) => + updateVendorContract(vendor.id, "ndaYn", !!checked) + } + /> + <label className="text-sm">NDA</label> + </div> + </div> + + {isInternational && ( + <div className="space-y-1"> + <Label className="text-xs">GTC</Label> + <RadioGroup + value={contract?.gtcType || "none"} + onValueChange={(value) => + updateVendorContract(vendor.id, "gtcType", value) + } + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="none" id={`${vendor.id}-none`} /> + <label htmlFor={`${vendor.id}-none`} className="text-xs">없음</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="general" id={`${vendor.id}-general`} /> + <label htmlFor={`${vendor.id}-general`} className="text-xs">General</label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="project" id={`${vendor.id}-project`} /> + <label htmlFor={`${vendor.id}-project`} className="text-xs">Project</label> + </div> + </RadioGroup> + </div> + )} + + {!isInternational && ( + <div className="text-xs text-muted-foreground"> + 국내 업체 - GTC 불필요 + </div> + )} + </div> + </div> + ); + })} + </div> + </ScrollArea> + </CardContent> + </Card> + </div> + </TabsContent> + </Tabs> {/* 푸터 */} <DialogFooter className="p-6 pt-0 border-t"> @@ -290,16 +553,25 @@ export function AddVendorDialog({ > 취소 </Button> - <Button - onClick={handleSubmit} - disabled={isLoading || selectedVendors.length === 0} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {selectedVendors.length > 0 - ? `${selectedVendors.length}개 벤더 추가` - : '벤더 추가' - } - </Button> + {activeTab === "vendors" && canProceedToContracts && ( + <Button + onClick={() => setActiveTab("contracts")} + > + 다음: 기본계약 설정 + </Button> + )} + {activeTab === "contracts" && ( + <Button + onClick={handleSubmit} + disabled={isLoading || selectedVendors.length === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {selectedVendors.length > 0 + ? `${selectedVendors.length}개 벤더 추가` + : '벤더 추가' + } + </Button> + )} </DialogFooter> </DialogContent> </Dialog> diff --git a/lib/rfq-last/vendor/delete-vendor-dialog.tsx b/lib/rfq-last/vendor/delete-vendor-dialog.tsx new file mode 100644 index 00000000..7634509e --- /dev/null +++ b/lib/rfq-last/vendor/delete-vendor-dialog.tsx @@ -0,0 +1,124 @@ +// components/delete-vendor-dialog.tsx +"use client"; + +import * as React from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { AlertTriangle, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { deleteRfqVendor } from "../service"; + +interface DeleteVendorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + vendorData: { + detailId: number; + vendorId: number; + vendorName: string; + vendorCode?: string | null; + hasQuotation: boolean; // quotationStatus가 있는지 여부 + }; + onSuccess?: () => void; +} + +export function DeleteVendorDialog({ + open, + onOpenChange, + rfqId, + vendorData, + onSuccess, +}: DeleteVendorDialogProps) { + const [isDeleting, setIsDeleting] = React.useState(false); + + const handleDelete = async () => { + // quotationStatus가 있으면 삭제 불가 (추가 보호) + if (vendorData.hasQuotation) { + toast.error("견적서가 제출된 벤더는 삭제할 수 없습니다."); + return; + } + + try { + setIsDeleting(true); + + const result = await deleteRfqVendor({ + rfqId, + detailId: vendorData.detailId, + vendorId: vendorData.vendorId, + }); + + if (result.success) { + toast.success(result.message || "벤더가 삭제되었습니다."); + onSuccess?.(); + onOpenChange(false); + } else { + toast.error(result.message || "삭제에 실패했습니다."); + } + } catch (error) { + console.error("벤더 삭제 실패:", error); + toast.error("삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleting(false); + } + }; + + return ( + <AlertDialog open={open} onOpenChange={onOpenChange}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <AlertTriangle className="h-5 w-5 text-destructive" /> + 벤더 삭제 확인 + </AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-2"> + <p> + <strong>{vendorData.vendorName}</strong> + {vendorData.vendorCode && ` (${vendorData.vendorCode})`}을(를) + RFQ 목록에서 삭제하시겠습니까? + </p> + + {vendorData.hasQuotation && ( + <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive"> + <p className="font-semibold">⚠️ 주의: 견적서가 제출된 벤더입니다.</p> + <p>견적서가 제출된 벤더는 삭제할 수 없습니다.</p> + </div> + )} + + {!vendorData.hasQuotation && ( + <p className="text-sm text-muted-foreground"> + 이 작업은 되돌릴 수 없습니다. 삭제 후에는 해당 벤더의 모든 RFQ 관련 정보가 제거됩니다. + </p> + )} + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleDelete} + disabled={isDeleting || vendorData.hasQuotation} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 삭제 중... + </> + ) : ( + "삭제" + )} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/edit-contract-dialog.tsx b/lib/rfq-last/vendor/edit-contract-dialog.tsx new file mode 100644 index 00000000..62b851fa --- /dev/null +++ b/lib/rfq-last/vendor/edit-contract-dialog.tsx @@ -0,0 +1,237 @@ +"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 { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { FileText, Shield, Globe, Info, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { updateVendorContractRequirements } from "../service"; + +interface EditContractDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + vendor: { + detailId: number; + vendorId: number; + vendorName: string; + vendorCode?: string; + vendorCountry?: string; + agreementYn?: boolean; + ndaYn?: boolean; + generalGtcYn?: boolean; + projectGtcYn?: boolean; + gtcType?: "general" | "project" | "none"; + }; + onSuccess: () => void; +} + +export function EditContractDialog({ + open, + onOpenChange, + rfqId, + vendor, + onSuccess, +}: EditContractDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + + // 기본계약 상태 + const [contractAgreement, setContractAgreement] = React.useState(false); + const [contractNDA, setContractNDA] = React.useState(false); + const [contractGTC, setContractGTC] = React.useState<"general" | "project" | "none">("none"); + + // 국외 업체 확인 + const isInternational = React.useMemo(() => { + return vendor?.vendorCountry && + vendor.vendorCountry !== "KR" && + vendor.vendorCountry !== "한국"; + }, [vendor]); + + // 초기값 설정 + React.useEffect(() => { + if (open && vendor) { + setContractAgreement(vendor.agreementYn || false); + setContractNDA(vendor.ndaYn || false); + + // GTC 타입 결정 + if (vendor.gtcType) { + setContractGTC(vendor.gtcType); + } else if (vendor.generalGtcYn) { + setContractGTC("general"); + } else if (vendor.projectGtcYn) { + setContractGTC("project"); + } else { + setContractGTC("none"); + } + } + }, [open, vendor]); + + // 제출 처리 + const handleSubmit = async () => { + setIsLoading(true); + + try { + const result = await updateVendorContractRequirements({ + rfqId, + detailId: vendor.detailId, + contractRequirements: { + agreementYn: contractAgreement, + ndaYn: contractNDA, + gtcType: isInternational ? contractGTC : "none", + }, + }); + + if (result.success) { + toast.success("기본계약 요구사항이 업데이트되었습니다."); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "업데이트에 실패했습니다."); + } + } catch (error) { + console.error("Update error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>기본계약 수정</DialogTitle> + <DialogDescription> + <div className="flex items-center gap-2 mt-2"> + <Badge variant="outline">{vendor?.vendorCode}</Badge> + <span className="text-sm font-medium">{vendor?.vendorName}</span> + {vendor?.vendorCountry && ( + <Badge + variant={isInternational ? "secondary" : "default"} + className="text-xs" + > + {vendor.vendorCountry} + </Badge> + )} + </div> + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + {/* 필수 계약 */} + <div className="space-y-3"> + <Label className="text-sm font-semibold">필수 계약</Label> + <div className="space-y-2"> + <div className="flex items-center space-x-2"> + <Checkbox + id="edit-agreement" + checked={contractAgreement} + onCheckedChange={(checked) => setContractAgreement(!!checked)} + /> + <label + htmlFor="edit-agreement" + className="flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + <FileText className="h-4 w-4 text-blue-500" /> + 기술자료 제공 동의 + </label> + </div> + <div className="flex items-center space-x-2"> + <Checkbox + id="edit-nda" + checked={contractNDA} + onCheckedChange={(checked) => setContractNDA(!!checked)} + /> + <label + htmlFor="edit-nda" + className="flex items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + <Shield className="h-4 w-4 text-green-500" /> + 비밀유지 계약 (NDA) + </label> + </div> + </div> + </div> + + {/* GTC 선택 (국외 업체만) */} + {isInternational && ( + <> + <Separator /> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <Label className="text-sm font-semibold flex items-center gap-2"> + <Globe className="h-4 w-4" /> + GTC (General Terms & Conditions) + </Label> + <Badge variant="outline" className="text-xs"> + 국외 업체 + </Badge> + </div> + <RadioGroup + value={contractGTC} + onValueChange={(value: any) => setContractGTC(value)} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="none" id="edit-gtc-none" /> + <label htmlFor="edit-gtc-none" className="text-sm"> + GTC 요구하지 않음 + </label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="general" id="edit-gtc-general" /> + <label htmlFor="edit-gtc-general" className="text-sm"> + General GTC + </label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="project" id="edit-gtc-project" /> + <label htmlFor="edit-gtc-project" className="text-sm"> + Project GTC + </label> + </div> + </RadioGroup> + </div> + </> + )} + + {/* 국내 업체 안내 */} + {!isInternational && ( + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 국내 업체는 GTC가 적용되지 않습니다. + </AlertDescription> + </Alert> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button onClick={handleSubmit} disabled={isLoading}> + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 저장 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index b2ea7588..830fd448 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -25,7 +25,9 @@ import { Package, MapPin, Info, - Loader2 + Loader2, + Router, + Shield } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -52,14 +54,18 @@ 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"; + import { getRfqSendData, getSelectedVendorsWithEmails, + sendRfqToVendors, type RfqSendData, type VendorEmailInfo } from "../service" +import { VendorResponseDetailDialog } from "./vendor-detail-dialog"; +import { DeleteVendorDialog } from "./delete-vendor-dialog"; +import { useRouter } from "next/navigation" +import { EditContractDialog } from "./edit-contract-dialog"; // 타입 정의 interface RfqDetail { @@ -91,20 +97,64 @@ interface RfqDetail { sparepartDescription?: string | null; updatedAt?: Date | null; updatedByUserName?: string | null; + emailSentAt: string | null; + emailSentTo: string | null; // JSON string + emailResentCount: number; + lastEmailSentAt: string | null; + emailStatus: string | null; } interface VendorResponse { id: number; - vendorId: number; - status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + rfqsLastId: number; + rfqLastDetailsId: number; responseVersion: number; isLatest: boolean; - submittedAt: Date | null; - totalAmount: number | null; - currency: string | null; - vendorDeliveryDate: Date | null; - quotedItemCount?: number; - attachmentCount?: number; + status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + vendor: { + id: number; + code: string | null; + name: string; + email: string; + }; + submission: { + submittedAt: Date | null; + submittedBy: string | null; + submittedByName: string | null; + }; + pricing: { + totalAmount: number | null; + currency: string | null; + vendorCurrency: string | null; + }; + vendorTerms: { + paymentTermsCode: string | null; + incotermsCode: string | null; + deliveryDate: Date | null; + contractDuration: string | null; + }; + additionalRequirements: { + firstArticle: { + required: boolean | null; + acceptance: boolean | null; + }; + sparePart: { + required: boolean | null; + acceptance: boolean | null; + }; + }; + counts: { + quotedItems: number; + attachments: number; + }; + remarks: { + general: string | null; + technical: string | null; + }; + timestamps: { + createdAt: string; + updatedAt: string; + }; } // Props 타입 정의 @@ -178,7 +228,7 @@ const mergeVendorData = ( ): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => { return rfqDetails.map(detail => { const response = vendorResponses.find( - r => r.vendorId === detail.vendorId && r.isLatest + r => r.vendor.id === detail.vendorId && r.isLatest ); return { ...detail, response, rfqCode }; }); @@ -208,6 +258,14 @@ export function RfqVendorTable({ const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); const [isSendDialogOpen, setIsSendDialogOpen] = React.useState(false); const [isLoadingSendData, setIsLoadingSendData] = React.useState(false); + const [deleteVendorData, setDeleteVendorData] = React.useState<{ + detailId: number; + vendorId: number; + vendorName: string; + vendorCode?: string | null; + hasResponse?: boolean; + responseStatus?: string | null; + } | null>(null); const [sendDialogData, setSendDialogData] = React.useState<{ rfqInfo: RfqSendData['rfqInfo'] | null; @@ -219,12 +277,19 @@ export function RfqVendorTable({ selectedVendors: [], }); + const [editContractVendor, setEditContractVendor] = React.useState<any | null>(null); + + + const router = useRouter() + // 데이터 병합 const mergedData = React.useMemo( () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), [rfqDetails, vendorResponses, rfqCode] ); + console.log(mergedData, "mergedData") + // 일괄 발송 핸들러 const handleBulkSend = React.useCallback(async () => { if (selectedRows.length === 0) { @@ -277,6 +342,11 @@ export function RfqVendorTable({ contactsByPosition: v.contactsByPosition || {}, primaryEmail: v.primaryEmail, currency: v.currency, + ndaYn: v.ndaYn, + generalGtcYn: v.generalGtcYn, + projectGtcYn: v.projectGtcYn, + agreementYn: v.agreementYn, + sendVersion: v.sendVersion })), }); @@ -297,25 +367,38 @@ export function RfqVendorTable({ vendorName: string; vendorCode?: string | null; vendorCountry?: string | null; - vendorEmail?: string | null; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails?: Array<{ email: string; name?: string }>; currency?: string | null; - additionalRecipients: string[]; + contractRequirements?: { + ndaYn: boolean; + generalGtcYn: boolean; + projectGtcYn: boolean; + agreementYn: boolean; + projectCode?: string; + }; + isResend: boolean; + sendVersion?: number; }>; attachments: number[]; message?: string; + generatedPdfs?: Array<{ // 타입 추가 + key: string; + buffer: number[]; + fileName: string; + }>; }) => { try { // 서버 액션 호출 - // const result = await sendRfqToVendors({ - // rfqId, - // rfqCode, - // vendors: data.vendors, - // attachmentIds: data.attachments, - // message: data.message, - // }); - - // 임시 성공 처리 - console.log("RFQ 발송 데이터:", data); + const result = await sendRfqToVendors({ + rfqId, + rfqCode, + vendors: data.vendors, + attachmentIds: data.attachments, + message: data.message, + generatedPdfs: data.generatedPdfs, + }); // 성공 후 처리 setSelectedRows([]); @@ -324,14 +407,23 @@ export function RfqVendorTable({ attachments: [], selectedVendors: [], }); + + // 기본계약 생성 결과 표시 + if (result.contractResults && result.contractResults.length > 0) { + const totalContracts = result.contractResults.reduce((acc, r) => acc + r.totalCreated, 0); + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송하고 ${totalContracts}개의 기본계약을 생성했습니다.`); + } else { + toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); + } - toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); + // 페이지 새로고침 + router.refresh(); } catch (error) { console.error("RFQ 발송 실패:", error); toast.error("RFQ 발송에 실패했습니다."); throw error; } - }, [rfqId, rfqCode]); + }, [rfqId, rfqCode, router]); // 액션 처리 const handleAction = React.useCallback(async (action: string, vendor: any) => { @@ -344,7 +436,7 @@ export function RfqVendorTable({ // 개별 RFQ 발송 try { setIsLoadingSendData(true); - + const [rfqSendData, vendorEmailInfos] = await Promise.all([ getRfqSendData(rfqId), getSelectedVendorsWithEmails(rfqId, [vendor.vendorId]) @@ -369,6 +461,11 @@ export function RfqVendorTable({ contactsByPosition: v.contactsByPosition || {}, primaryEmail: v.primaryEmail, currency: v.currency, + ndaYn: v.ndaYn, + generalGtcYn: v.generalGtcYn, + projectGtcYn: v.projectGtcYn, + agreementYn: v.agreementYn, + sendVersion: v.sendVersion, })), }); @@ -385,10 +482,29 @@ export function RfqVendorTable({ toast.info("수정 기능은 준비중입니다."); break; + case "edit-contract": + // 기본계약 수정 + setEditContractVendor(vendor); + break; + case "delete": - if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { - toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); + // quotationStatus 체크 + const hasQuotation = !!vendor.quotationStatus; + + if (hasQuotation) { + // 견적서가 있으면 즉시 에러 토스트 표시 + toast.error("이미 발송된 벤더는 삭제할 수 없습니다."); + return; } + + // 삭제 다이얼로그 열기 + setDeleteVendorData({ + detailId: vendor.detailId, + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + hasQuotation: hasQuotation, + }); break; case "response-detail": @@ -486,12 +602,188 @@ export function RfqVendorTable({ }, size: 100, }, + { - accessorKey: "basicContract", - header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, - cell: ({ row }) => row.original.basicContract || "-", - size: 100, + accessorKey: "contractRequirements", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />, + cell: ({ row }) => { + const vendor = row.original; + const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; + + // 기본계약 상태 확인 + const requirements = []; + + // 필수 계약들 + if (vendor.agreementYn) { + requirements.push({ + name: "기술자료", + icon: <FileText className="h-3 w-3" />, + color: "text-blue-500" + }); + } + + if (vendor.ndaYn) { + requirements.push({ + name: "NDA", + icon: <Shield className="h-3 w-3" />, + color: "text-green-500" + }); + } + + // GTC (국외 업체만) + if (!isKorean) { + if (vendor.generalGtcYn || vendor.gtcType === "general") { + requirements.push({ + name: "General GTC", + icon: <Globe className="h-3 w-3" />, + color: "text-purple-500" + }); + } else if (vendor.projectGtcYn || vendor.gtcType === "project") { + requirements.push({ + name: "Project GTC", + icon: <Globe className="h-3 w-3" />, + color: "text-indigo-500" + }); + } + } + + if (requirements.length === 0) { + return <span className="text-xs text-muted-foreground">없음</span>; + } + + return ( + <div className="flex flex-wrap gap-1"> + {requirements.map((req, idx) => ( + <TooltipProvider key={idx}> + <Tooltip> + <TooltipTrigger asChild> + <Badge variant="outline" className="text-xs px-1.5 py-0"> + <span className={cn("mr-1", req.color)}> + {req.icon} + </span> + {req.name} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p className="text-xs"> + {req.name === "기술자료" && "기술자료 제공 동의서"} + {req.name === "NDA" && "비밀유지 계약서"} + {req.name === "General GTC" && "일반 거래 약관"} + {req.name === "Project GTC" && "프로젝트별 거래 약관"} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ))} + </div> + ); + }, + size: 150, }, + + { + accessorKey: "sendVersion", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="발송 회차" />, + cell: ({ row }) => { + const version = row.original.sendVersion; + + + return <span>{version}</span>; + }, + size: 80, + }, + { + accessorKey: "emailStatus", + header: "이메일 상태", + cell: ({ row }) => { + const response = row.original; + const emailSentAt = response?.emailSentAt; + const emailResentCount = response?.emailResentCount || 0; + const emailStatus = response?.emailStatus; + const status = response?.status; + + if (!emailSentAt) { + return ( + <Badge variant="outline" className="bg-gray-50"> + <Mail className="h-3 w-3 mr-1" /> + 미발송 + </Badge> + ); + } + + // 이메일 상태 표시 (failed인 경우 특별 처리) + const getEmailStatusBadge = () => { + if (emailStatus === "failed") { + return ( + <Badge variant="destructive"> + <XCircle className="h-3 w-3 mr-1" /> + 발송 실패 + </Badge> + ); + } + return ( + <Badge variant={status === "제출완료" ? "success" : "default"}> + {getStatusIcon(status || "")} + {status} + </Badge> + ); + }; + + // emailSentTo JSON 파싱 + let recipients = { to: [], cc: [], sentBy: "" }; + try { + if (response?.email?.emailSentTo) { + recipients = JSON.parse(response.email.emailSentTo); + } + } catch (e) { + console.error("Failed to parse emailSentTo", e); + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <div className="flex flex-col gap-1"> + {getEmailStatusBadge()} + {emailResentCount > 1 && ( + <Badge variant="secondary" className="text-xs"> + 재발송 {emailResentCount - 1}회 + </Badge> + )} + </div> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p>최초 발송: {format(new Date(emailSentAt), "yyyy-MM-dd HH:mm")}</p> + {response?.email?.lastEmailSentAt && ( + <p>최근 발송: {format(new Date(response.email.lastEmailSentAt), "yyyy-MM-dd HH:mm")}</p> + )} + {recipients.to.length > 0 && ( + <p>수신자: {recipients.to.join(", ")}</p> + )} + {recipients.cc.length > 0 && ( + <p>참조: {recipients.cc.join(", ")}</p> + )} + {recipients.sentBy && ( + <p>발신자: {recipients.sentBy}</p> + )} + {emailStatus === "failed" && ( + <p className="text-red-500 font-semibold">⚠️ 이메일 발송 실패</p> + )} + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }, + size: 120, + }, + // { + // accessorKey: "basicContract", + // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, + // cell: ({ row }) => row.original.basicContract || "-", + // size: 100, + // }, { accessorKey: "currency", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />, @@ -641,20 +933,23 @@ export function RfqVendorTable({ size: 120, }, { - accessorKey: "response.submittedAt", + accessorKey: "response.submission.submittedAt", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, cell: ({ row }) => { - const submittedAt = row.original.response?.submittedAt; + const participationRepliedAt = row.original.response?.attend?.participationRepliedAt; - if (!submittedAt) { - return <Badge variant="outline">미참여</Badge>; + if (!participationRepliedAt) { + return <Badge variant="outline">미응답</Badge>; } + + const participationStatus = row.original.response?.attend?.participationStatus; + return ( <div className="flex flex-col gap-0.5"> - <Badge variant="default" className="text-xs">참여</Badge> + <Badge variant="default" className="text-xs">{participationStatus}</Badge> <span className="text-xs text-muted-foreground"> - {format(new Date(submittedAt), "MM-dd")} + {format(new Date(participationRepliedAt), "yyyy-MM-dd")} </span> </div> ); @@ -665,7 +960,7 @@ export function RfqVendorTable({ id: "responseDetail", header: "회신상세", cell: ({ row }) => { - const hasResponse = !!row.original.response?.submittedAt; + const hasResponse = !!row.original.response?.submission?.submittedAt; if (!hasResponse) { return <span className="text-muted-foreground text-xs">-</span>; @@ -731,6 +1026,10 @@ export function RfqVendorTable({ cell: ({ row }) => { const vendor = row.original; const hasResponse = !!vendor.response; + const emailSentAt = vendor.response?.email?.emailSentAt; + const emailResentCount = vendor.response?.email?.emailResentCount || 0; + const hasQuotation = !!vendor.quotationStatus; + const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; return ( <DropdownMenu> @@ -747,8 +1046,33 @@ export function RfqVendorTable({ <Eye className="mr-2 h-4 w-4" /> 상세보기 </DropdownMenuItem> - {!hasResponse && ( - <DropdownMenuItem + + {/* 기본계약 수정 메뉴 추가 */} + <DropdownMenuItem onClick={() => handleAction("edit-contract", vendor)}> + <FileText className="mr-2 h-4 w-4" /> + 기본계약 수정 + </DropdownMenuItem> + + {emailSentAt && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleAction("resend", vendor)} + disabled={isLoadingSendData} + > + <RefreshCw className="mr-2 h-4 w-4" /> + 이메일 재발송 + {emailResentCount > 0 && ( + <Badge variant="outline" className="ml-2 text-xs"> + {emailResentCount} + </Badge> + )} + </DropdownMenuItem> + </> + )} + + {!emailSentAt && ( + <DropdownMenuItem onClick={() => handleAction("send", vendor)} disabled={isLoadingSendData} > @@ -756,24 +1080,28 @@ export function RfqVendorTable({ RFQ 발송 </DropdownMenuItem> )} - <DropdownMenuItem onClick={() => handleAction("edit", vendor)}> - <Edit className="mr-2 h-4 w-4" /> - 조건 수정 - </DropdownMenuItem> + <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => handleAction("delete", vendor)} - className="text-red-600" + className={cn( + "text-red-600", + hasQuotation && "opacity-50 cursor-not-allowed" + )} + disabled={hasQuotation} > <Trash2 className="mr-2 h-4 w-4" /> 삭제 + {hasQuotation && ( + <span className="ml-2 text-xs">(불가)</span> + )} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }, size: 60, - }, + } ], [handleAction, rfqCode, isLoadingSendData]); const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ @@ -850,7 +1178,7 @@ export function RfqVendorTable({ ) : ( <> <Send className="h-4 w-4 mr-2" /> - 선택 발송 ({selectedRows.length}) + RFQ 발송 ({selectedRows.length}) </> )} </Button> @@ -924,14 +1252,43 @@ export function RfqVendorTable({ /> {/* 벤더 상세 다이얼로그 */} - {/* {selectedVendor && ( - <VendorDetailDialog + {selectedVendor && ( + <VendorResponseDetailDialog open={!!selectedVendor} onOpenChange={(open) => !open && setSelectedVendor(null)} - vendor={selectedVendor} + data={selectedVendor} + rfqId={rfqId} + /> + )} + + {/* 삭제 다이얼로그 추가 */} + {deleteVendorData && ( + <DeleteVendorDialog + open={!!deleteVendorData} + onOpenChange={(open) => !open && setDeleteVendorData(null)} rfqId={rfqId} + vendorData={deleteVendorData} + onSuccess={() => { + setDeleteVendorData(null); + router.refresh(); + // 데이터 새로고침 + }} /> - )} */} + )} + + {/* 기본계약 수정 다이얼로그 - 새로 추가 */} + {editContractVendor && ( + <EditContractDialog + open={!!editContractVendor} + onOpenChange={(open) => !open && setEditContractVendor(null)} + rfqId={rfqId} + vendor={editContractVendor} + onSuccess={() => { + setEditContractVendor(null); + router.refresh(); + }} + /> + )} </> ); }
\ No newline at end of file diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index 9d88bdc9..619ea749 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -39,7 +39,9 @@ import { Building, ChevronDown, ChevronRight, - UserPlus + UserPlus, + Shield, + Globe } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -74,6 +76,25 @@ import { PopoverTrigger, PopoverContent, } from "@/components/ui/popover" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Progress } from "@/components/ui/progress"; + +interface ContractToGenerate { + vendorId: number; + vendorName: string; + type: string; + templateName: string; +} + // 타입 정의 interface ContactDetail { id: number; @@ -102,6 +123,15 @@ interface Vendor { contactsByPosition?: Record<string, ContactDetail[]>; primaryEmail?: string | null; currency?: string | null; + + // 기본계약 정보 + ndaYn?: boolean; + generalGtcYn?: boolean; + projectGtcYn?: boolean; + agreementYn?: boolean; + + // 발송 정보 + sendVersion?: number; } interface Attachment { @@ -149,9 +179,29 @@ interface SendRfqDialogProps { rfqInfo: RfqInfo; attachments?: Attachment[]; onSend: (data: { - vendors: VendorWithRecipients[]; + vendors: Array<{ + vendorId: number; + vendorName: string; + vendorCode?: string | null; + vendorCountry?: string | null; + selectedMainEmail: string; + additionalEmails: string[]; + customEmails?: Array<{ email: string; name?: string }>; + currency?: string | null; + contractRequirements?: { + ndaYn: boolean; + generalGtcYn: boolean; + projectGtcYn: boolean; + agreementYn: boolean; + projectCode?: string; + }; + isResend: boolean; + sendVersion?: number; + contractsSkipped?: boolean; + }>; attachments: number[]; message?: string; + generatedPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>; }) => Promise<void>; } @@ -175,49 +225,6 @@ const getAttachmentIcon = (type: string) => { } }; -// 파일 크기 포맷 -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`; -}; - -// 포지션별 아이콘 -const getPositionIcon = (position?: string | null) => { - if (!position) return <User className="h-3 w-3" />; - - const lowerPosition = position.toLowerCase(); - if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { - return <Building2 className="h-3 w-3" />; - } - if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { - return <Briefcase className="h-3 w-3" />; - } - if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { - return <Package className="h-3 w-3" />; - } - return <User className="h-3 w-3" />; -}; - -// 포지션별 색상 -const getPositionColor = (position?: string | null) => { - if (!position) return 'default'; - - const lowerPosition = position.toLowerCase(); - if (lowerPosition.includes('대표') || lowerPosition.includes('ceo')) { - return 'destructive'; - } - if (lowerPosition.includes('영업') || lowerPosition.includes('sales')) { - return 'success'; - } - if (lowerPosition.includes('기술') || lowerPosition.includes('tech')) { - return 'secondary'; - } - return 'default'; -}; - export function SendRfqDialog({ open, onOpenChange, @@ -226,6 +233,7 @@ export function SendRfqDialog({ attachments = [], onSend, }: SendRfqDialogProps) { + const [isSending, setIsSending] = React.useState(false); const [vendorsWithRecipients, setVendorsWithRecipients] = React.useState<VendorWithRecipients[]>([]); const [selectedAttachments, setSelectedAttachments] = React.useState<number[]>([]); @@ -233,6 +241,118 @@ export function SendRfqDialog({ const [expandedVendors, setExpandedVendors] = React.useState<number[]>([]); const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({}); const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({}); + const [showResendConfirmDialog, setShowResendConfirmDialog] = React.useState(false); + const [resendVendorsInfo, setResendVendorsInfo] = React.useState<{ count: number; names: string[] }>({ count: 0, names: [] }); + + const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false); + const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0); + const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState(""); + const [generatedPdfs, setGeneratedPdfs] = React.useState<Map<string, { buffer: number[], fileName: string }>>(new Map()); + + // 재전송 시 기본계약 스킵 옵션 - 업체별 관리 + const [skipContractsForVendor, setSkipContractsForVendor] = React.useState<Record<number, boolean>>({}); + + const generateContractPdf = async ( + vendor: VendorWithRecipients, + contractType: string, + templateName: string + ): Promise<{ buffer: number[], fileName: string }> => { + try { + // 1. 템플릿 데이터 준비 (서버 액션 호출) + const prepareResponse = await fetch("/api/contracts/prepare-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + templateName, + vendorId: vendor.vendorId, + }), + }); + + if (!prepareResponse.ok) { + throw new Error("템플릿 준비 실패"); + } + + const { template, templateData } = await prepareResponse.json(); + + // 2. 템플릿 파일 다운로드 + const templateResponse = await fetch("/api/contracts/get-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templatePath: template.filePath }), + }); + + const templateBlob = await templateResponse.blob(); + const templateFile = new window.File([templateBlob], "template.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + + // 3. PDFtron WebViewer로 PDF 변환 + const pdfBuffer = await convertToPdfWithWebViewer(templateFile, templateData); + + const fileName = `${contractType}_${vendor.vendorCode || vendor.vendorId}_${Date.now()}.pdf`; + + return { + buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환 + fileName + }; + } catch (error) { + console.error(`PDF 생성 실패 (${vendor.vendorName} - ${contractType}):`, error); + throw error; + } + }; + + // PDFtron WebViewer 변환 함수 + const convertToPdfWithWebViewer = async ( + templateFile: File, + templateData: Record<string, string> + ): Promise<Uint8Array> => { + const { default: WebViewer } = await import("@pdftron/webviewer"); + + const tempDiv = document.createElement('div'); + tempDiv.style.display = 'none'; + tempDiv.style.position = 'absolute'; + tempDiv.style.top = '-9999px'; + tempDiv.style.left = '-9999px'; + tempDiv.style.width = '1px'; + tempDiv.style.height = '1px'; + document.body.appendChild(tempDiv); + + try { + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + }, + tempDiv + ); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + const { Core } = instance; + const { createDocument } = Core; + + const templateDoc = await createDocument(templateFile, { + filename: templateFile.name, + extension: 'docx', + }); + + await templateDoc.applyTemplateValues(templateData); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const fileData = await templateDoc.getFileData(); + const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' }); + + instance.UI.dispose(); + return new Uint8Array(pdfBuffer); + + } finally { + if (tempDiv.parentNode) { + document.body.removeChild(tempDiv); + } + } + }; // 초기화 React.useEffect(() => { @@ -254,6 +374,15 @@ export function SendRfqDialog({ // 초기화 setCustomEmailInputs({}); setShowCustomEmailForm({}); + + // 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성) + const skipOptions: Record<number, boolean> = {}; + selectedVendors.forEach(v => { + if (v.sendVersion && v.sendVersion > 0) { + skipOptions[v.vendorId] = false; // 기본값은 재생성 + } + }); + setSkipContractsForVendor(skipOptions); } }, [open, selectedVendors, attachments]); @@ -378,11 +507,145 @@ export function SendRfqDialog({ ); }; - // 전송 처리 - const handleSend = async () => { + // 실제 발송 처리 함수 (재발송 확인 후 또는 바로 실행) + const proceedWithSend = React.useCallback(async () => { try { setIsSending(true); + + // 기본계약이 필요한 계약서 목록 수집 + const contractsToGenerate: ContractToGenerate[] = []; + + for (const vendor of vendorsWithRecipients) { + // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기 + const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0; + if (isResendVendor && skipContractsForVendor[vendor.vendorId]) { + continue; // 이 벤더의 계약서 생성을 스킵 + } + + if (vendor.ndaYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "NDA", + templateName: "비밀" + }); + } + if (vendor.generalGtcYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "General_GTC", + templateName: "General GTC" + }); + } + if (vendor.projectGtcYn && rfqInfo?.projectCode) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "Project_GTC", + templateName: rfqInfo.projectCode + }); + } + if (vendor.agreementYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "기술자료", + templateName: "기술" + }); + } + } + + let pdfsMap = new Map<string, { buffer: number[], fileName: string }>(); + // PDF 생성이 필요한 경우 + if (contractsToGenerate.length > 0) { + setIsGeneratingPdfs(true); + setPdfGenerationProgress(0); + + try { + let completed = 0; + + for (const contract of contractsToGenerate) { + setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`); + + const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId); + if (!vendor) continue; + + const pdf = await generateContractPdf(vendor, contract.type, contract.templateName); + pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf); + + completed++; + setPdfGenerationProgress((completed / contractsToGenerate.length) * 100); + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + setGeneratedPdfs(pdfsMap); // UI 업데이트용 + } catch (error) { + console.error("PDF 생성 실패:", error); + toast.error("기본계약서 생성에 실패했습니다."); + setIsGeneratingPdfs(false); + setPdfGenerationProgress(0); + return; + } + } + + // RFQ 발송 - pdfsMap을 직접 사용 + setIsGeneratingPdfs(false); + setIsSending(true); + + await onSend({ + vendors: vendorsWithRecipients.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + selectedMainEmail: v.selectedMainEmail, + additionalEmails: v.additionalEmails, + customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })), + currency: v.currency, + contractRequirements: { + ndaYn: v.ndaYn || false, + generalGtcYn: v.generalGtcYn || false, + projectGtcYn: v.projectGtcYn || false, + agreementYn: v.agreementYn || false, + projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined, + }, + isResend: (v.sendVersion || 0) > 0, + sendVersion: v.sendVersion, + contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId], + })), + attachments: selectedAttachments, + message: additionalMessage, + // 생성된 PDF 데이터 추가 + generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({ + key, + ...data + })), + }); + + toast.success( + `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` + + (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '') + ); + onOpenChange(false); + + } catch (error) { + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + } finally { + setIsSending(false); + setIsGeneratingPdfs(false); + setPdfGenerationProgress(0); + setCurrentGeneratingContract(""); + setSkipContractsForVendor({}); // 초기화 + } +}, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]); + + // 전송 처리 + const handleSend = async () => { + try { // 유효성 검사 const vendorsWithoutEmail = vendorsWithRecipients.filter(v => !v.selectedMainEmail); if (vendorsWithoutEmail.length > 0) { @@ -395,22 +658,23 @@ export function SendRfqDialog({ return; } - await onSend({ - vendors: vendorsWithRecipients.map(v => ({ - ...v, - additionalRecipients: v.additionalEmails, - })), - attachments: selectedAttachments, - message: additionalMessage, - }); + // 재발송 업체 확인 + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + if (resendVendors.length > 0) { + // AlertDialog를 표시하기 위해 상태 설정 + setResendVendorsInfo({ + count: resendVendors.length, + names: resendVendors.map(v => v.vendorName) + }); + setShowResendConfirmDialog(true); + return; // 여기서 일단 중단하고 다이얼로그 응답을 기다림 + } - toast.success(`${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.`); - onOpenChange(false); + // 재발송 업체가 없으면 바로 진행 + await proceedWithSend(); } catch (error) { - console.error("RFQ 발송 실패:", error); - toast.error("RFQ 발송에 실패했습니다."); - } finally { - setIsSending(false); + console.error("RFQ 발송 준비 실패:", error); + toast.error("RFQ 발송 준비에 실패했습니다."); } }; @@ -437,6 +701,35 @@ export function SendRfqDialog({ {/* ScrollArea 대신 div 사용 */} <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(90vh - 200px)' }}> <div className="space-y-6 pr-4"> + {/* 재발송 경고 메시지 - 재발송 업체가 있을 때만 표시 */} + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && ( + <Alert className="border-yellow-500 bg-yellow-50"> + <AlertCircle className="h-4 w-4 text-yellow-600" /> + <AlertTitle className="text-yellow-800">재발송 경고</AlertTitle> + <AlertDescription className="text-yellow-700 space-y-3"> + <ul className="list-disc list-inside space-y-1"> + <li>재발송 대상 업체의 기존 견적 데이터가 초기화됩니다.</li> + <li>업체는 새로운 버전의 견적서를 작성해야 합니다.</li> + <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> + </ul> + + {/* 기본계약 재발송 정보 */} + <div className="mt-3 pt-3 border-t border-yellow-400"> + <div className="space-y-2"> + <p className="text-sm font-medium flex items-center gap-2"> + <FileText className="h-4 w-4" /> + 기본계약서 재발송 설정 + </p> + <p className="text-xs text-yellow-600"> + 각 재발송 업체별로 기본계약서 재생성 여부를 선택할 수 있습니다. + 아래 표에서 업체별로 설정해주세요. + </p> + </div> + </div> + </AlertDescription> + </Alert> + )} + {/* RFQ 정보 섹션 */} <div className="space-y-4"> <div className="flex items-center gap-2 text-sm font-medium"> @@ -521,6 +814,40 @@ export function SendRfqDialog({ <tr> <th className="text-left p-2 text-xs font-medium">No.</th> <th className="text-left p-2 text-xs font-medium">업체명</th> + <th className="text-left p-2 text-xs font-medium">기본계약</th> + <th className="text-left p-2 text-xs font-medium"> + <div className="flex items-center gap-2"> + <span>계약서 재발송</span> + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0) && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-5 px-1 text-xs" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const allChecked = resendVendors.every(v => !skipContractsForVendor[v.vendorId]); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = allChecked; + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" : + Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"} + </Button> + </TooltipTrigger> + <TooltipContent> + 재발송 업체 전체 선택/해제 + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + </th> <th className="text-left p-2 text-xs font-medium">주 수신자</th> <th className="text-left p-2 text-xs font-medium">CC</th> <th className="text-left p-2 text-xs font-medium">작업</th> @@ -559,13 +886,41 @@ export function SendRfqDialog({ const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail); const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail); const isFormOpen = showCustomEmailForm[vendor.vendorId]; + const isResend = vendor.sendVersion && vendor.sendVersion > 0; + + // 기본계약 요구사항 확인 + const contracts = []; + if (vendor.ndaYn) contracts.push({ name: "NDA", icon: <Shield className="h-3 w-3" /> }); + if (vendor.generalGtcYn) contracts.push({ name: "General GTC", icon: <Globe className="h-3 w-3" /> }); + if (vendor.projectGtcYn) contracts.push({ name: "Project GTC", icon: <Building className="h-3 w-3" /> }); + if (vendor.agreementYn) contracts.push({ name: "기술자료", icon: <FileText className="h-3 w-3" /> }); return ( <React.Fragment key={vendor.vendorId}> <tr className="border-b hover:bg-muted/20"> <td className="p-2"> - <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> - {index + 1} + <div className="flex items-center gap-1"> + <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> + {index + 1} + </div> + {isResend && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge variant="warning" className="text-xs"> + 재발송 + </Badge> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p className="font-semibold">⚠️ 재발송 경고</p> + <p className="text-xs">발송 회차: {vendor.sendVersion + 1}회차</p> + <p className="text-xs text-yellow-600">기존 견적 데이터가 초기화됩니다</p> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} </div> </td> <td className="p-2"> @@ -582,6 +937,86 @@ export function SendRfqDialog({ </div> </td> <td className="p-2"> + {contracts.length > 0 ? ( + <div className="flex flex-wrap gap-1"> + {/* 재전송이고 스킵 옵션이 켜져 있으면 표시 */} + {isResend && skipContractsForVendor[vendor.vendorId] ? ( + <Badge variant="secondary" className="text-xs px-1"> + <CheckCircle className="h-3 w-3 mr-1 text-green-500" /> + <span>기존 계약서 유지</span> + </Badge> + ) : ( + contracts.map((contract, idx) => ( + <TooltipProvider key={idx}> + <Tooltip> + <TooltipTrigger> + <Badge variant="outline" className="text-xs px-1"> + {contract.icon} + <span className="ml-1">{contract.name}</span> + {isResend && !skipContractsForVendor[vendor.vendorId] && ( + <RefreshCw className="h-3 w-3 ml-1 text-orange-500" /> + )} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p className="text-xs"> + {contract.name === "NDA" && "비밀유지 계약서 요청"} + {contract.name === "General GTC" && "일반 거래약관 요청"} + {contract.name === "Project GTC" && `프로젝트 거래약관 요청 (${rfqInfo?.projectCode})`} + {contract.name === "기술자료" && "기술자료 제공 동의서 요청"} + {isResend && !skipContractsForVendor[vendor.vendorId] && ( + <span className="block mt-1 text-orange-400">⚠️ 재생성됨</span> + )} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )) + )} + </div> + ) : ( + <span className="text-xs text-muted-foreground">없음</span> + )} + </td> + <td className="p-2"> + {isResend && contracts.length > 0 ? ( + <div className="flex items-center justify-center"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-2"> + <Checkbox + checked={!skipContractsForVendor[vendor.vendorId]} + onCheckedChange={(checked) => { + setSkipContractsForVendor(prev => ({ + ...prev, + [vendor.vendorId]: !checked + })); + }} + // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + /> + <span className="text-xs"> + {skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"} + </span> + </div> + </TooltipTrigger> + <TooltipContent> + <p className="text-xs"> + {skipContractsForVendor[vendor.vendorId] + ? "기존 계약서를 그대로 유지합니다" + : "기존 계약서를 삭제하고 새로 생성합니다"} + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + ) : ( + <span className="text-xs text-muted-foreground text-center block"> + {isResend ? "계약서 없음" : "-"} + </span> + )} + </td> + <td className="p-2"> <Select value={vendor.selectedMainEmail} onValueChange={(value) => handleMainEmailChange(vendor.vendorId, value)} @@ -676,7 +1111,7 @@ export function SendRfqDialog({ {/* 인라인 수신자 추가 폼 - 한 줄 레이아웃 */} {isFormOpen && ( <tr className="bg-muted/10 border-b"> - <td colSpan={5} className="p-4"> + <td colSpan={7} className="p-4"> <div className="space-y-3"> <div className="flex items-center justify-between mb-2"> <div className="flex items-center gap-2 text-sm font-medium"> @@ -871,6 +1306,29 @@ export function SendRfqDialog({ onChange={(e) => setAdditionalMessage(e.target.value)} /> </div> + + {/* 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> + )} + + </div> </div> @@ -892,9 +1350,14 @@ export function SendRfqDialog({ </Button> <Button onClick={handleSend} - disabled={isSending || selectedAttachments.length === 0} + disabled={isSending || isGeneratingPdfs || selectedAttachments.length === 0} > - {isSending ? ( + {isGeneratingPdfs ? ( + <> + <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> + 계약서 생성중... ({Math.round(pdfGenerationProgress)}%) + </> + ) : isSending ? ( <> <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> 발송중... @@ -908,6 +1371,150 @@ export function SendRfqDialog({ </Button> </DialogFooter> </DialogContent> + + {/* 재발송 확인 다이얼로그 */} + <AlertDialog open={showResendConfirmDialog} onOpenChange={setShowResendConfirmDialog}> + <AlertDialogContent className="max-w-2xl"> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <AlertCircle className="h-5 w-5 text-yellow-600" /> + 재발송 확인 + </AlertDialogTitle> + <AlertDialogDescription asChild> + <div className="space-y-4"> + <p className="text-sm"> + <span className="font-semibold text-yellow-700">{resendVendorsInfo.count}개 업체</span>가 재발송 대상입니다. + </p> + + {/* 재발송 대상 업체 목록 및 계약서 설정 */} + <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3"> + <p className="text-sm font-medium text-yellow-800 mb-3">재발송 대상 업체 및 계약서 설정:</p> + <div className="space-y-2"> + {vendorsWithRecipients + .filter(v => v.sendVersion && v.sendVersion > 0) + .map(vendor => { + const contracts = []; + if (vendor.ndaYn) contracts.push("NDA"); + if (vendor.generalGtcYn) contracts.push("General GTC"); + if (vendor.projectGtcYn) contracts.push("Project GTC"); + if (vendor.agreementYn) contracts.push("기술자료"); + + return ( + <div key={vendor.vendorId} className="flex items-center justify-between p-2 bg-white rounded border border-yellow-100"> + <div className="flex items-center gap-3"> + <span className="w-1.5 h-1.5 bg-yellow-600 rounded-full" /> + <div> + <span className="text-sm font-medium text-yellow-900">{vendor.vendorName}</span> + {contracts.length > 0 && ( + <div className="text-xs text-yellow-700 mt-0.5"> + 계약서: {contracts.join(", ")} + </div> + )} + </div> + </div> + {contracts.length > 0 && ( + <div className="flex items-center gap-2"> + <Checkbox + checked={!skipContractsForVendor[vendor.vendorId]} + onCheckedChange={(checked) => { + setSkipContractsForVendor(prev => ({ + ...prev, + [vendor.vendorId]: !checked + })); + }} + className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + /> + <span className="text-xs text-yellow-800"> + {skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"} + </span> + </div> + )} + </div> + ); + })} + </div> + + {/* 전체 선택 버튼 */} + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 && + (v.ndaYn || v.generalGtcYn || v.projectGtcYn || v.agreementYn)) && ( + <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = true; // 모두 유지 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 유지 + </Button> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = false; // 모두 재생성 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 재생성 + </Button> + </div> + )} + </div> + + {/* 경고 메시지 */} + <Alert className="border-red-200 bg-red-50"> + <AlertCircle className="h-4 w-4 text-red-600" /> + <AlertTitle className="text-red-800">중요 안내사항</AlertTitle> + <AlertDescription className="text-red-700 space-y-2"> + <ul className="list-disc list-inside space-y-1 text-sm"> + <li>기존에 작성된 견적 데이터가 <strong>모두 초기화</strong>됩니다.</li> + <li>업체는 처음부터 새로 견적서를 작성해야 합니다.</li> + <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> + {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip && + vendorsWithRecipients.find(v => v.vendorId === Number(vendorId))) && ( + <li className="text-orange-700 font-medium"> + ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다. + </li> + )} + <li>이 작업은 <strong>취소할 수 없습니다</strong>.</li> + </ul> + </AlertDescription> + </Alert> + + <p className="text-sm text-muted-foreground"> + 재발송을 진행하시겠습니까? + </p> + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => { + setShowResendConfirmDialog(false); + // 취소 시 옵션은 유지 (사용자가 설정한 상태 그대로) + }}> + 취소 + </AlertDialogCancel> + <AlertDialogAction + onClick={() => { + setShowResendConfirmDialog(false); + proceedWithSend(); + }} + > + <RefreshCw className="h-4 w-4 mr-2" /> + 재발송 진행 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> </Dialog> ); }
\ No newline at end of file diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx index e69de29b..e4c78656 100644 --- a/lib/rfq-last/vendor/vendor-detail-dialog.tsx +++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx @@ -0,0 +1,695 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { + Building2, + Calendar, + DollarSign, + FileText, + Package, + Globe, + MapPin, + Clock, + CheckCircle, + XCircle, + AlertCircle, + Download, + Eye, + User, + Mail, + Phone, + CreditCard, + Truck, + Shield, + Paperclip, + Info, + Edit, +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +// Props 타입 정의 +interface VendorResponseDetailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + data: any; // mergedData의 row + rfqId: number; +} + +// 상태별 설정 +const getStatusConfig = (status: string) => { + switch (status) { + case "초대됨": + return { + icon: <Mail className="h-4 w-4" />, + color: "text-blue-600", + bgColor: "bg-blue-50", + variant: "secondary" as const, + }; + case "작성중": + return { + icon: <Clock className="h-4 w-4" />, + color: "text-yellow-600", + bgColor: "bg-yellow-50", + variant: "outline" as const, + }; + case "제출완료": + return { + icon: <CheckCircle className="h-4 w-4" />, + color: "text-green-600", + bgColor: "bg-green-50", + variant: "default" as const, + }; + case "수정요청": + return { + icon: <AlertCircle className="h-4 w-4" />, + color: "text-orange-600", + bgColor: "bg-orange-50", + variant: "warning" as const, + }; + case "최종확정": + return { + icon: <Shield className="h-4 w-4" />, + color: "text-indigo-600", + bgColor: "bg-indigo-50", + variant: "success" as const, + }; + case "취소": + return { + icon: <XCircle className="h-4 w-4" />, + color: "text-red-600", + bgColor: "bg-red-50", + variant: "destructive" as const, + }; + default: + return { + icon: <Info className="h-4 w-4" />, + color: "text-gray-600", + bgColor: "bg-gray-50", + variant: "outline" as const, + }; + } +}; + +export function VendorResponseDetailDialog({ + open, + onOpenChange, + data, + rfqId, +}: VendorResponseDetailDialogProps) { + if (!data) return null; + + const response = data.response; + const statusConfig = getStatusConfig(response?.status || "초대됨"); + const hasSubmitted = !!response?.submission?.submittedAt; + + // 이메일 발송 정보 파싱 + let emailRecipients = { to: [], cc: [], sentBy: "" }; + try { + if (data.emailSentTo) { + emailRecipients = JSON.parse(data.emailSentTo); + } + } catch (e) { + console.error("Failed to parse emailSentTo"); + } + + // 견적 아이템 (실제로는 response.quotationItems에서 가져옴) + const quotationItems = response?.quotationItems || []; + + // 첨부파일 (실제로는 response.attachments에서 가져옴) + const attachments = response?.attachments || []; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <div className="flex items-center justify-between"> + <div> + <DialogTitle className="text-xl font-bold"> + 벤더 응답 상세 + </DialogTitle> + <DialogDescription className="mt-1"> + {data.vendorName} ({data.vendorCode || "코드없음"}) - {data.rfqCode} + </DialogDescription> + </div> + <div className="flex items-center gap-2"> + {/* {onEdit && ( + <Button variant="outline" size="sm" onClick={onEdit}> + <Edit className="h-4 w-4 mr-2" /> + 수정 + </Button> + )} */} + </div> + </div> + </DialogHeader> + + <Tabs defaultValue="overview" className="mt-4"> + <TabsList className="grid w-full grid-cols-4"> + <TabsTrigger value="overview">개요</TabsTrigger> + <TabsTrigger value="quotation">견적정보</TabsTrigger> + <TabsTrigger value="items">품목상세</TabsTrigger> + <TabsTrigger value="attachments">첨부파일</TabsTrigger> + </TabsList> + + {/* 개요 탭 */} + <TabsContent value="overview" className="space-y-4"> + {/* 상태 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">응답 상태</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">현재 상태</span> + <Badge variant={statusConfig.variant}> + {statusConfig.icon} + <span className="ml-1">{response?.status || "초대됨"}</span> + </Badge> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">응답 버전</span> + <span className="font-medium">v{response?.responseVersion || 1}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">Short List</span> + <Badge variant={data.shortList ? "default" : "outline"}> + {data.shortList ? "선정" : "대기"} + </Badge> + </div> + </div> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제출일시</span> + <span className="text-sm"> + {response?.submission?.submittedAt + ? format(new Date(response.submission.submittedAt), "yyyy-MM-dd HH:mm", { locale: ko }) + : "-"} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제출자</span> + <span className="text-sm">{response?.submission?.submittedByName || "-"}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">최종 수정일</span> + <span className="text-sm"> + {data.updatedAt + ? format(new Date(data.updatedAt), "yyyy-MM-dd HH:mm", { locale: ko }) + : "-"} + </span> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 벤더 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">벤더 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">업체명</span> + <span className="font-medium ml-auto">{data.vendorName}</span> + </div> + <div className="flex items-center gap-2"> + <Package className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">업체코드</span> + <span className="font-medium ml-auto">{data.vendorCode || "-"}</span> + </div> + <div className="flex items-center gap-2"> + <Globe className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">국가</span> + <Badge variant={data.vendorCountry === "KR" ? "default" : "secondary"} className="ml-auto"> + {data.vendorCountry} + </Badge> + </div> + </div> + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Mail className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">이메일</span> + <span className="text-sm ml-auto">{response?.vendor?.email || "-"}</span> + </div> + <div className="flex items-center gap-2"> + <Shield className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">업체분류</span> + <span className="font-medium ml-auto">{data.vendorCategory || "-"}</span> + </div> + <div className="flex items-center gap-2"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground">AVL 등급</span> + <span className="font-medium ml-auto">{data.vendorGrade || "-"}</span> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 이메일 발송 정보 */} + {data.emailSentAt && ( + <Card> + <CardHeader> + <CardTitle className="text-base">이메일 발송 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">최초 발송일시</span> + <span className="text-sm"> + {format(new Date(data.emailSentAt), "yyyy-MM-dd HH:mm", { locale: ko })} + </span> + </div> + {data.lastEmailSentAt && data.emailResentCount > 1 && ( + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">최근 재발송일시</span> + <span className="text-sm"> + {format(new Date(data.lastEmailSentAt), "yyyy-MM-dd HH:mm", { locale: ko })} + <Badge variant="secondary" className="ml-2"> + 재발송 {data.emailResentCount - 1}회 + </Badge> + </span> + </div> + )} + {emailRecipients.to.length > 0 && ( + <div className="flex items-start justify-between"> + <span className="text-sm text-muted-foreground">수신자</span> + <span className="text-sm text-right">{emailRecipients.to.join(", ")}</span> + </div> + )} + {emailRecipients.cc.length > 0 && ( + <div className="flex items-start justify-between"> + <span className="text-sm text-muted-foreground">참조</span> + <span className="text-sm text-right">{emailRecipients.cc.join(", ")}</span> + </div> + )} + {emailRecipients.sentBy && ( + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">발신자</span> + <span className="text-sm">{emailRecipients.sentBy}</span> + </div> + )} + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">발송 상태</span> + <Badge variant={data.emailStatus === "failed" ? "destructive" : "default"}> + {data.emailStatus === "failed" ? "발송 실패" : "발송 완료"} + </Badge> + </div> + </div> + </CardContent> + </Card> + )} + </TabsContent> + + {/* 견적정보 탭 */} + <TabsContent value="quotation" className="space-y-4"> + {/* 요청 조건 */} + <Card> + <CardHeader> + <CardTitle className="text-base">요청 조건</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">통화</span> + <Badge variant="outline">{data.currency}</Badge> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">지급조건</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <span className="font-medium">{data.paymentTermsCode}</span> + </TooltipTrigger> + {data.paymentTermsDescription && ( + <TooltipContent> + <p>{data.paymentTermsDescription}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">인코텀즈</span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <span className="font-medium">{data.incotermsCode}</span> + </TooltipTrigger> + {data.incotermsDescription && ( + <TooltipContent> + <p>{data.incotermsDescription}</p> + {data.incotermsDetail && <p className="text-xs">{data.incotermsDetail}</p>} + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">Tax</span> + <span className="font-medium">{data.taxCode || "-"}</span> + </div> + </div> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">납기일</span> + <span className="text-sm"> + {data.deliveryDate + ? format(new Date(data.deliveryDate), "yyyy-MM-dd") + : "-"} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">계약기간</span> + <span className="text-sm">{data.contractDuration || "-"}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">선적지</span> + <span className="text-sm">{data.placeOfShipping || "-"}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">도착지</span> + <span className="text-sm">{data.placeOfDestination || "-"}</span> + </div> + </div> + </div> + + {/* 추가 조건 */} + <Separator className="my-4" /> + <div className="space-y-3"> + {data.firstYn && ( + <div className="flex items-start justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline">초도품</Badge> + <span className="text-sm text-muted-foreground">요구사항</span> + </div> + <span className="text-sm text-right max-w-xs">{data.firstDescription || "초도품 제출 필요"}</span> + </div> + )} + {data.sparepartYn && ( + <div className="flex items-start justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline">스페어파트</Badge> + <span className="text-sm text-muted-foreground">요구사항</span> + </div> + <span className="text-sm text-right max-w-xs">{data.sparepartDescription || "스페어파트 제공 필요"}</span> + </div> + )} + {data.materialPriceRelatedYn && ( + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Badge variant="outline">연동제</Badge> + <span className="text-sm text-muted-foreground">적용</span> + </div> + <span className="text-sm">적용</span> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 벤더 제안 조건 (제출된 경우) */} + {hasSubmitted && ( + <Card> + <CardHeader> + <CardTitle className="text-base">벤더 제안 조건</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제안 통화</span> + <Badge variant={response?.pricing?.vendorCurrency === data.currency ? "outline" : "default"}> + {response?.pricing?.vendorCurrency || data.currency} + </Badge> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제안 지급조건</span> + <span className="font-medium"> + {response?.vendorTerms?.paymentTermsCode || data.paymentTermsCode} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제안 인코텀즈</span> + <span className="font-medium"> + {response?.vendorTerms?.incotermsCode || data.incotermsCode} + </span> + </div> + </div> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">제안 납기일</span> + <span className="text-sm"> + {response?.vendorTerms?.deliveryDate + ? format(new Date(response.vendorTerms.deliveryDate), "yyyy-MM-dd") + : data.deliveryDate + ? format(new Date(data.deliveryDate), "yyyy-MM-dd") + : "-"} + </span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">총 견적금액</span> + <span className="font-bold text-lg"> + {response?.pricing?.totalAmount + ? new Intl.NumberFormat("ko-KR", { + style: "currency", + currency: response.pricing.vendorCurrency || data.currency, + }).format(response.pricing.totalAmount) + : "-"} + </span> + </div> + </div> + </div> + + {/* 벤더 추가 응답 */} + {(response?.additionalRequirements?.firstArticle?.acceptance || + response?.additionalRequirements?.sparePart?.acceptance) && ( + <> + <Separator className="my-4" /> + <div className="space-y-3"> + {response?.additionalRequirements?.firstArticle?.acceptance && ( + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">초도품 수용여부</span> + <Badge + variant={ + response.additionalRequirements.firstArticle.acceptance === "수용" + ? "default" + : response.additionalRequirements.firstArticle.acceptance === "부분수용" + ? "secondary" + : "destructive" + } + > + {response.additionalRequirements.firstArticle.acceptance} + </Badge> + </div> + )} + {response?.additionalRequirements?.sparePart?.acceptance && ( + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">스페어파트 수용여부</span> + <Badge + variant={ + response.additionalRequirements.sparePart.acceptance === "수용" + ? "default" + : response.additionalRequirements.sparePart.acceptance === "부분수용" + ? "secondary" + : "destructive" + } + > + {response.additionalRequirements.sparePart.acceptance} + </Badge> + </div> + )} + </div> + </> + )} + + {/* 벤더 비고 */} + {(response?.remarks?.general || response?.remarks?.technical) && ( + <> + <Separator className="my-4" /> + <div className="space-y-3"> + {response?.remarks?.general && ( + <div> + <span className="text-sm text-muted-foreground">일반 비고</span> + <p className="mt-1 text-sm">{response.remarks.general}</p> + </div> + )} + {response?.remarks?.technical && ( + <div> + <span className="text-sm text-muted-foreground">기술 제안</span> + <p className="mt-1 text-sm">{response.remarks.technical}</p> + </div> + )} + </div> + </> + )} + </CardContent> + </Card> + )} + </TabsContent> + + {/* 품목상세 탭 */} + <TabsContent value="items" className="space-y-4"> + {quotationItems.length > 0 ? ( + <Card> + <CardHeader> + <CardTitle className="text-base">견적 품목 상세</CardTitle> + <CardDescription> + 총 {quotationItems.length}개 품목 + </CardDescription> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead>PR No.</TableHead> + <TableHead>자재코드</TableHead> + <TableHead>자재명</TableHead> + <TableHead className="text-right">수량</TableHead> + <TableHead>단위</TableHead> + <TableHead className="text-right">단가</TableHead> + <TableHead className="text-right">금액</TableHead> + <TableHead>납기일</TableHead> + <TableHead>제조사</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {quotationItems.map((item: any) => ( + <TableRow key={item.id}> + <TableCell className="font-mono text-xs">{item.prNo}</TableCell> + <TableCell className="font-mono text-xs">{item.materialCode}</TableCell> + <TableCell className="text-xs">{item.materialDescription}</TableCell> + <TableCell className="text-right">{item.quantity}</TableCell> + <TableCell>{item.uom}</TableCell> + <TableCell className="text-right"> + {new Intl.NumberFormat("ko-KR").format(item.unitPrice)} + </TableCell> + <TableCell className="text-right font-medium"> + {new Intl.NumberFormat("ko-KR").format(item.totalPrice)} + </TableCell> + <TableCell> + {item.vendorDeliveryDate + ? format(new Date(item.vendorDeliveryDate), "MM-dd") + : "-"} + </TableCell> + <TableCell>{item.manufacturer || "-"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + ) : ( + <Card> + <CardContent className="pt-6"> + <div className="text-center text-muted-foreground"> + 아직 제출된 견적 품목이 없습니다. + </div> + </CardContent> + </Card> + )} + </TabsContent> + + {/* 첨부파일 탭 */} + <TabsContent value="attachments" className="space-y-4"> + {attachments.length > 0 ? ( + <Card> + <CardHeader> + <CardTitle className="text-base">첨부파일</CardTitle> + <CardDescription> + 총 {attachments.length}개 파일 + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-2"> + {attachments.map((file: any) => ( + <div + key={file.id} + className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent" + > + <div className="flex items-center gap-3"> + <Paperclip className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm font-medium">{file.originalFileName}</p> + <p className="text-xs text-muted-foreground"> + {file.attachmentType} • {file.fileSize ? `${(file.fileSize / 1024).toFixed(2)} KB` : "크기 미상"} + {file.description && ` • ${file.description}`} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + <Button + variant="ghost" + size="sm" + onClick={() => { + // 파일 미리보기 로직 + console.log("Preview file:", file.filePath); + }} + > + <Eye className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => { + // 파일 다운로드 로직 + window.open(file.filePath, "_blank"); + }} + > + <Download className="h-4 w-4" /> + </Button> + </div> + </div> + ))} + </div> + </CardContent> + </Card> + ) : ( + <Card> + <CardContent className="pt-6"> + <div className="text-center text-muted-foreground"> + 아직 제출된 첨부파일이 없습니다. + </div> + </CardContent> + </Card> + )} + </TabsContent> + </Tabs> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/tbe-last/service.ts b/lib/tbe-last/service.ts new file mode 100644 index 00000000..760f66ac --- /dev/null +++ b/lib/tbe-last/service.ts @@ -0,0 +1,247 @@ +// lib/tbe-last/service.ts +'use server' + +import { unstable_cache } from "next/cache"; +import db from "@/db/db"; +import { and, desc, asc, eq, sql, or, isNull, isNotNull, ne, inArray } from "drizzle-orm"; +import { tbeLastView, tbeDocumentsView } from "@/db/schema"; +import { rfqPrItems } from "@/db/schema/rfqLast"; +import { rfqLastTbeDocumentReviews, rfqLastTbePdftronComments, rfqLastTbeVendorDocuments } from "@/db/schema"; +import { filterColumns } from "@/lib/filter-columns"; +import { GetTBELastSchema } from "./validations"; + +// ========================================== +// 1. TBE 세션 목록 조회 +// ========================================== +export async function getAllTBELast(input: GetTBELastSchema) { + return unstable_cache( + async () => { + // 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); + const limit = input.perPage ?? 10; + + // 고급 필터 + const advancedWhere = filterColumns({ + table: tbeLastView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${tbeLastView.sessionCode} ILIKE ${s}`, + sql`${tbeLastView.rfqCode} ILIKE ${s}`, + sql`${tbeLastView.vendorName} ILIKE ${s}`, + sql`${tbeLastView.vendorCode} ILIKE ${s}`, + sql`${tbeLastView.projectCode} ILIKE ${s}`, + sql`${tbeLastView.projectName} ILIKE ${s}`, + sql`${tbeLastView.packageNo} ILIKE ${s}`, + sql`${tbeLastView.packageName} ILIKE ${s}` + ); + } + + // 최종 WHERE + const finalWhere = and(advancedWhere, globalWhere); + + // 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + const col = (tbeLastView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [desc(tbeLastView.createdAt)]; + + // 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select() + .from(tbeLastView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(tbeLastView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + const pageCount = Math.ceil(total / limit); + return { data: rows, pageCount }; + }, + [JSON.stringify(input)], + { + revalidate: 60, + tags: ["tbe-last-sessions"], + } + )(); +} + +// ========================================== +// 2. TBE 세션 상세 조회 +// ========================================== +export async function getTBESessionDetail(sessionId: number) { + return unstable_cache( + async () => { + // 세션 기본 정보 + const [session] = await db + .select() + .from(tbeLastView) + .where(eq(tbeLastView.tbeSessionId, sessionId)) + .limit(1); + + if (!session) { + return null; + } + + // PR 아이템 목록 + const prItems = await db + .select() + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, session.rfqId)) + .orderBy(desc(rfqPrItems.majorYn), asc(rfqPrItems.prItem)); + + // 문서 목록 (구매자 + 벤더) + const documents = await db + .select() + .from(tbeDocumentsView) + .where(eq(tbeDocumentsView.tbeSessionId, sessionId)) + .orderBy( + sql`CASE document_source WHEN 'buyer' THEN 0 ELSE 1 END`, + asc(tbeDocumentsView.documentName) + ); + + // PDFTron 코멘트 통계 + const comments = await db + .select({ + documentReviewId: rfqLastTbePdftronComments.documentReviewId, + totalCount: sql<number>`count(*)`.as("total_count"), + openCount: sql<number>`sum(case when status = 'open' then 1 else 0 end)`.as("open_count"), + }) + .from(rfqLastTbePdftronComments) + .innerJoin( + rfqLastTbeDocumentReviews, + eq(rfqLastTbePdftronComments.documentReviewId, rfqLastTbeDocumentReviews.id) + ) + .where(eq(rfqLastTbeDocumentReviews.tbeSessionId, sessionId)) + .groupBy(rfqLastTbePdftronComments.documentReviewId); + + // 문서별 코멘트 수 매핑 + const commentsByDocumentId = new Map( + comments.map(c => [c.documentReviewId, { + totalCount: c.totalCount, + openCount: c.openCount + }]) + ); + + // 문서에 코멘트 정보 추가 + const documentsWithComments = documents.map(doc => ({ + ...doc, + comments: doc.documentReviewId + ? commentsByDocumentId.get(doc.documentReviewId) || { totalCount: 0, openCount: 0 } + : { totalCount: 0, openCount: 0 } + })); + + return { + session, + prItems, + documents: documentsWithComments, + }; + }, + [`tbe-session-${sessionId}`], + { + revalidate: 60, + tags: [`tbe-session-${sessionId}`], + } + )(); +} + +// ========================================== +// 3. 문서별 PDFTron 코멘트 조회 +// ========================================== +export async function getDocumentComments(documentReviewId: number) { + const comments = await db + .select({ + id: rfqLastTbePdftronComments.id, + pdftronAnnotationId: rfqLastTbePdftronComments.pdftronAnnotationId, + pageNumber: rfqLastTbePdftronComments.pageNumber, + commentText: rfqLastTbePdftronComments.commentText, + commentCategory: rfqLastTbePdftronComments.commentCategory, + severity: rfqLastTbePdftronComments.severity, + status: rfqLastTbePdftronComments.status, + createdBy: rfqLastTbePdftronComments.createdBy, + createdByType: rfqLastTbePdftronComments.createdByType, + createdAt: rfqLastTbePdftronComments.createdAt, + resolvedBy: rfqLastTbePdftronComments.resolvedBy, + resolvedAt: rfqLastTbePdftronComments.resolvedAt, + resolutionNote: rfqLastTbePdftronComments.resolutionNote, + replies: rfqLastTbePdftronComments.replies, + }) + .from(rfqLastTbePdftronComments) + .where(eq(rfqLastTbePdftronComments.documentReviewId, documentReviewId)) + .orderBy(asc(rfqLastTbePdftronComments.pageNumber), desc(rfqLastTbePdftronComments.createdAt)); + + return comments; +} + +// ========================================== +// 4. TBE 평가 결과 업데이트 +// ========================================== +export async function updateTBEEvaluation( + sessionId: number, + data: { + evaluationResult: "pass" | "conditional_pass" | "non_pass"; + conditionalRequirements?: string; + technicalSummary?: string; + commercialSummary?: string; + overallRemarks?: string; + } +) { + // 실제 업데이트 로직 + // await db.update(rfqLastTbeSessions)... + + // 캐시 무효화 + return { success: true }; +} + +// ========================================== +// 5. 벤더 문서 업로드 +// ========================================== +export async function uploadVendorDocument( + sessionId: number, + file: { + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number; + fileType: string; + documentType: string; + description?: string; + } +) { + const [document] = await db + .insert(rfqLastTbeVendorDocuments) + .values({ + tbeSessionId: sessionId, + documentType: file.documentType as any, + fileName: file.fileName, + originalFileName: file.originalFileName, + filePath: file.filePath, + fileSize: file.fileSize, + fileType: file.fileType, + description: file.description, + reviewRequired: true, + reviewStatus: "pending", + submittedBy: 1, // TODO: 실제 사용자 ID + submittedAt: new Date(), + }) + .returning(); + + return document; +}
\ No newline at end of file diff --git a/lib/tbe-last/table/tbe-last-table-columns.tsx b/lib/tbe-last/table/tbe-last-table-columns.tsx new file mode 100644 index 00000000..71b3acde --- /dev/null +++ b/lib/tbe-last/table/tbe-last-table-columns.tsx @@ -0,0 +1,376 @@ +// lib/tbe-last/table/tbe-last-table-columns.tsx + +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { FileText, MessageSquare, Package, ListChecks } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { TbeLastView } from "@/db/schema/tbeLastView" + +interface GetColumnsProps { + onOpenSessionDetail: (sessionId: number) => void; + onOpenDocuments: (sessionId: number) => void; + onOpenPrItems: (rfqId: number) => void; + onOpenEvaluation: (session: TbeLastView) => void; +} + +export function getColumns({ + onOpenSessionDetail, + onOpenDocuments, + onOpenPrItems, + onOpenEvaluation, +}: GetColumnsProps): ColumnDef<TbeLastView>[] { + + const columns: ColumnDef<TbeLastView>[] = [ + // Select Column + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // TBE Session Code + { + accessorKey: "sessionCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="TBE Code" /> + ), + cell: ({ row }) => { + const sessionId = row.original.tbeSessionId; + const sessionCode = row.original.sessionCode; + + return ( + <Button + variant="link" + className="p-0 h-auto font-medium" + onClick={() => onOpenSessionDetail(sessionId)} + > + {sessionCode} + </Button> + ); + }, + size: 120, + }, + + // RFQ Info Group + { + id: "rfqInfo", + header: "RFQ Information", + columns: [ + { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> + ), + cell: ({ row }) => row.original.rfqCode, + size: 120, + }, + { + accessorKey: "rfqTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Title" /> + ), + cell: ({ row }) => row.original.rfqTitle || "-", + size: 200, + }, + { + accessorKey: "rfqDueDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Due Date" /> + ), + cell: ({ row }) => { + const date = row.original.rfqDueDate; + return date ? formatDate(date, "KR") : "-"; + }, + size: 100, + }, + ], + }, + + // Package Info + { + id: "packageInfo", + header: "Package", + columns: [ + { + accessorKey: "packageNo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Package No" /> + ), + cell: ({ row }) => { + const packageNo = row.original.packageNo; + const packageName = row.original.packageName; + + if (!packageNo) return "-"; + + return ( + <div className="flex flex-col"> + <span className="font-medium">{packageNo}</span> + {packageName && ( + <span className="text-xs text-muted-foreground">{packageName}</span> + )} + </div> + ); + }, + size: 150, + }, + ], + }, + + // Project Info + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => { + const projectCode = row.original.projectCode; + const projectName = row.original.projectName; + + if (!projectCode) return "-"; + + return ( + <div className="flex flex-col"> + <span className="font-medium">{projectCode}</span> + {projectName && ( + <span className="text-xs text-muted-foreground">{projectName}</span> + )} + </div> + ); + }, + size: 150, + }, + + // Vendor Info + { + id: "vendorInfo", + header: "Vendor", + columns: [ + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Code" /> + ), + cell: ({ row }) => row.original.vendorCode || "-", + size: 100, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Name" /> + ), + cell: ({ row }) => row.original.vendorName, + size: 200, + }, + ], + }, + + // TBE Status + { + accessorKey: "sessionStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Status" /> + ), + cell: ({ row }) => { + const status = row.original.sessionStatus; + + let variant: "default" | "secondary" | "outline" | "destructive" = "outline"; + + switch (status) { + case "준비중": + variant = "outline"; + break; + case "진행중": + variant = "default"; + break; + case "검토중": + variant = "secondary"; + break; + case "완료": + variant = "default"; + break; + case "보류": + variant = "destructive"; + break; + } + + return <Badge variant={variant}>{status}</Badge>; + }, + size: 100, + }, + + // Evaluation Result + { + accessorKey: "evaluationResult", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Result" /> + ), + cell: ({ row }) => { + const result = row.original.evaluationResult; + const session = row.original; + + if (!result) { + return ( + <Button + variant="outline" + size="sm" + onClick={() => onOpenEvaluation(session)} + > + 평가입력 + </Button> + ); + } + + let variant: "default" | "secondary" | "destructive" = "default"; + let displayText = result; + + switch (result) { + case "pass": + variant = "default"; + displayText = "Pass"; + break; + case "conditional_pass": + variant = "secondary"; + displayText = "Conditional"; + break; + case "non_pass": + variant = "destructive"; + displayText = "Non-Pass"; + break; + } + + return ( + <Button + variant="link" + className="p-0 h-auto" + onClick={() => onOpenEvaluation(session)} + > + <Badge variant={variant}>{displayText}</Badge> + </Button> + ); + }, + size: 120, + }, + + // PR Items + { + id: "prItems", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="PR Items" /> + ), + cell: ({ row }) => { + const rfqId = row.original.rfqId; + const totalCount = row.original.prItemsCount; + const majorCount = row.original.majorItemsCount; + + return ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2" + onClick={() => onOpenPrItems(rfqId)} + > + <ListChecks className="h-4 w-4 mr-1" /> + <span className="text-xs"> + {totalCount} ({majorCount}) + </span> + </Button> + ); + }, + size: 100, + enableSorting: false, + }, + + // Documents + { + id: "documents", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Documents" /> + ), + cell: ({ row }) => { + const sessionId = row.original.tbeSessionId; + const buyerDocs = row.original.buyerDocumentsCount; + const vendorDocs = row.original.vendorDocumentsCount; + const reviewedDocs = row.original.reviewedDocumentsCount; + const totalDocs = buyerDocs + vendorDocs; + + return ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2" + onClick={() => onOpenDocuments(sessionId)} + > + <FileText className="h-4 w-4 mr-1" /> + <span className="text-xs"> + {reviewedDocs}/{totalDocs} + </span> + </Button> + ); + }, + size: 100, + enableSorting: false, + }, + + // Comments + { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const sessionId = row.original.tbeSessionId; + const totalComments = row.original.totalCommentsCount; + const unresolvedComments = row.original.unresolvedCommentsCount; + + return ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2 relative" + onClick={() => onOpenDocuments(sessionId)} + > + <MessageSquare className="h-4 w-4" /> + {totalComments > 0 && ( + <Badge + variant={unresolvedComments > 0 ? "destructive" : "secondary"} + className="absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem]" + > + {unresolvedComments > 0 ? unresolvedComments : totalComments} + </Badge> + )} + </Button> + ); + }, + size: 80, + enableSorting: false, + }, + ]; + + return columns; +}
\ No newline at end of file diff --git a/lib/tbe-last/table/tbe-last-table.tsx b/lib/tbe-last/table/tbe-last-table.tsx new file mode 100644 index 00000000..64707e4e --- /dev/null +++ b/lib/tbe-last/table/tbe-last-table.tsx @@ -0,0 +1,419 @@ +// lib/tbe-last/table/tbe-last-table.tsx + +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { type DataTableFilterField } from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getColumns } from "./tbe-last-table-columns" +import { TbeLastView } from "@/db/schema" +import { getAllTBELast, getTBESessionDetail } from "@/lib/tbe-last/service" +import { Button } from "@/components/ui/button" +import { Download, RefreshCw } from "lucide-react" +import { exportTableToExcel } from "@/lib/export" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from "@/components/ui/dialog" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription +} from "@/components/ui/sheet" +import { Badge } from "@/components/ui/badge" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { formatDate } from "@/lib/utils" + +interface TbeLastTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getAllTBELast>>, + ]> +} + +export function TbeLastTable({ promises }: TbeLastTableProps) { + const router = useRouter() + const [{ data, pageCount }] = React.use(promises) + + // Dialog states + const [sessionDetailOpen, setSessionDetailOpen] = React.useState(false) + const [documentsOpen, setDocumentsOpen] = React.useState(false) + const [prItemsOpen, setPrItemsOpen] = React.useState(false) + const [evaluationOpen, setEvaluationOpen] = React.useState(false) + + const [selectedSessionId, setSelectedSessionId] = React.useState<number | null>(null) + const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) + const [selectedSession, setSelectedSession] = React.useState<TbeLastView | null>(null) + const [sessionDetail, setSessionDetail] = React.useState<any>(null) + const [isLoadingDetail, setIsLoadingDetail] = React.useState(false) + + // Load session detail when needed + const loadSessionDetail = React.useCallback(async (sessionId: number) => { + setIsLoadingDetail(true) + try { + const detail = await getTBESessionDetail(sessionId) + setSessionDetail(detail) + } catch (error) { + console.error("Failed to load session detail:", error) + } finally { + setIsLoadingDetail(false) + } + }, []) + + // Handlers + const handleOpenSessionDetail = React.useCallback((sessionId: number) => { + setSelectedSessionId(sessionId) + setSessionDetailOpen(true) + loadSessionDetail(sessionId) + }, [loadSessionDetail]) + + const handleOpenDocuments = React.useCallback((sessionId: number) => { + setSelectedSessionId(sessionId) + setDocumentsOpen(true) + loadSessionDetail(sessionId) + }, [loadSessionDetail]) + + const handleOpenPrItems = React.useCallback((rfqId: number) => { + setSelectedRfqId(rfqId) + setPrItemsOpen(true) + loadSessionDetail(rfqId) + }, [loadSessionDetail]) + + const handleOpenEvaluation = React.useCallback((session: TbeLastView) => { + setSelectedSession(session) + setEvaluationOpen(true) + }, []) + + const handleRefresh = React.useCallback(() => { + router.refresh() + }, [router]) + + // Table columns + const columns = React.useMemo( + () => + getColumns({ + onOpenSessionDetail: handleOpenSessionDetail, + onOpenDocuments: handleOpenDocuments, + onOpenPrItems: handleOpenPrItems, + onOpenEvaluation: handleOpenEvaluation, + }), + [handleOpenSessionDetail, handleOpenDocuments, handleOpenPrItems, handleOpenEvaluation] + ) + + // Filter fields + const filterFields: DataTableFilterField<TbeLastView>[] = [ + { + id: "sessionStatus", + label: "Status", + type: "select", + options: [ + { label: "준비중", value: "준비중" }, + { label: "진행중", value: "진행중" }, + { label: "검토중", value: "검토중" }, + { label: "보류", value: "보류" }, + { label: "완료", value: "완료" }, + ], + }, + { + id: "evaluationResult", + label: "Result", + type: "select", + options: [ + { label: "Pass", value: "pass" }, + { label: "Conditional Pass", value: "conditional_pass" }, + { label: "Non-Pass", value: "non_pass" }, + ], + }, + ] + + // Data table + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["documents", "comments"] }, + }, + getRowId: (originalRow) => String(originalRow.tbeSessionId), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={filterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + className="gap-2" + > + <RefreshCw className="size-4" /> + <span>Refresh</span> + </Button> + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tbe-sessions", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" /> + <span>Export</span> + </Button> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* Session Detail Dialog */} + <Dialog open={sessionDetailOpen} onOpenChange={setSessionDetailOpen}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>TBE Session Detail</DialogTitle> + <DialogDescription> + {sessionDetail?.session?.sessionCode} - {sessionDetail?.session?.vendorName} + </DialogDescription> + </DialogHeader> + {isLoadingDetail ? ( + <div className="p-8 text-center">Loading...</div> + ) : sessionDetail ? ( + <div className="space-y-4"> + {/* Session info */} + <div className="grid grid-cols-2 gap-4"> + <div> + <p className="text-sm font-medium">RFQ Code</p> + <p className="text-sm text-muted-foreground">{sessionDetail.session.rfqCode}</p> + </div> + <div> + <p className="text-sm font-medium">Status</p> + <Badge>{sessionDetail.session.sessionStatus}</Badge> + </div> + <div> + <p className="text-sm font-medium">Project</p> + <p className="text-sm text-muted-foreground"> + {sessionDetail.session.projectCode} - {sessionDetail.session.projectName} + </p> + </div> + <div> + <p className="text-sm font-medium">Package</p> + <p className="text-sm text-muted-foreground"> + {sessionDetail.session.packageNo} - {sessionDetail.session.packageName} + </p> + </div> + </div> + + {/* PR Items */} + {sessionDetail.prItems?.length > 0 && ( + <div> + <h3 className="font-medium mb-2">PR Items</h3> + <div className="border rounded-lg"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b"> + <th className="text-left p-2">PR No</th> + <th className="text-left p-2">Material Code</th> + <th className="text-left p-2">Description</th> + <th className="text-left p-2">Qty</th> + <th className="text-left p-2">Delivery</th> + </tr> + </thead> + <tbody> + {sessionDetail.prItems.map((item: any) => ( + <tr key={item.id} className="border-b"> + <td className="p-2">{item.prNo}</td> + <td className="p-2">{item.materialCode}</td> + <td className="p-2">{item.materialDescription}</td> + <td className="p-2">{item.quantity} {item.uom}</td> + <td className="p-2"> + {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"} + </td> + </tr> + ))} + </tbody> + </table> + </div> + </div> + )} + </div> + ) : null} + </DialogContent> + </Dialog> + + {/* Documents Sheet */} + <Sheet open={documentsOpen} onOpenChange={setDocumentsOpen}> + <SheetContent className="w-[600px] sm:w-[800px]"> + <SheetHeader> + <SheetTitle>Documents & Comments</SheetTitle> + <SheetDescription> + Review documents and PDFTron comments + </SheetDescription> + </SheetHeader> + + {isLoadingDetail ? ( + <div className="p-8 text-center">Loading...</div> + ) : sessionDetail?.documents ? ( + <Tabs defaultValue="buyer" className="mt-4"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="buyer">Buyer Documents</TabsTrigger> + <TabsTrigger value="vendor">Vendor Documents</TabsTrigger> + </TabsList> + + <TabsContent value="buyer"> + <ScrollArea className="h-[calc(100vh-200px)]"> + <div className="space-y-2"> + {sessionDetail.documents + .filter((doc: any) => doc.documentSource === "buyer") + .map((doc: any) => ( + <div key={doc.documentId} className="border rounded-lg p-3"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <p className="font-medium text-sm">{doc.documentName}</p> + <p className="text-xs text-muted-foreground"> + Type: {doc.documentType} | Status: {doc.reviewStatus} + </p> + </div> + <div className="flex items-center gap-2"> + {doc.comments.totalCount > 0 && ( + <Badge variant={doc.comments.openCount > 0 ? "destructive" : "secondary"}> + {doc.comments.openCount}/{doc.comments.totalCount} comments + </Badge> + )} + <Button size="sm" variant="outline"> + View in PDFTron + </Button> + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </TabsContent> + + <TabsContent value="vendor"> + <ScrollArea className="h-[calc(100vh-200px)]"> + <div className="space-y-2"> + {sessionDetail.documents + .filter((doc: any) => doc.documentSource === "vendor") + .map((doc: any) => ( + <div key={doc.documentId} className="border rounded-lg p-3"> + <div className="flex items-start justify-between"> + <div className="flex-1"> + <p className="font-medium text-sm">{doc.documentName}</p> + <p className="text-xs text-muted-foreground"> + Type: {doc.documentType} | Status: {doc.reviewStatus} + </p> + {doc.submittedAt && ( + <p className="text-xs text-muted-foreground"> + Submitted: {formatDate(doc.submittedAt, "KR")} + </p> + )} + </div> + <div className="flex items-center gap-2"> + <Button size="sm" variant="outline"> + Download + </Button> + <Button size="sm" variant="outline"> + Review + </Button> + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </TabsContent> + </Tabs> + ) : null} + </SheetContent> + </Sheet> + + {/* PR Items Dialog */} + <Dialog open={prItemsOpen} onOpenChange={setPrItemsOpen}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>PR Items</DialogTitle> + <DialogDescription> + Purchase Request items for this RFQ + </DialogDescription> + </DialogHeader> + {sessionDetail?.prItems && ( + <div className="border rounded-lg"> + <table className="w-full text-sm"> + <thead> + <tr className="border-b bg-muted/50"> + <th className="text-left p-2">PR No</th> + <th className="text-left p-2">Material Code</th> + <th className="text-left p-2">Description</th> + <th className="text-left p-2">Size</th> + <th className="text-left p-2">Qty</th> + <th className="text-left p-2">Unit</th> + <th className="text-left p-2">Delivery</th> + <th className="text-left p-2">Major</th> + </tr> + </thead> + <tbody> + {sessionDetail.prItems.map((item: any) => ( + <tr key={item.id} className="border-b hover:bg-muted/20"> + <td className="p-2">{item.prNo}</td> + <td className="p-2">{item.materialCode}</td> + <td className="p-2">{item.materialDescription}</td> + <td className="p-2">{item.size || "-"}</td> + <td className="p-2 text-right">{item.quantity}</td> + <td className="p-2">{item.uom}</td> + <td className="p-2"> + {item.deliveryDate ? formatDate(item.deliveryDate, "KR") : "-"} + </td> + <td className="p-2 text-center"> + {item.majorYn && <Badge variant="default">Major</Badge>} + </td> + </tr> + ))} + </tbody> + </table> + </div> + )} + </DialogContent> + </Dialog> + + {/* Evaluation Dialog */} + <Dialog open={evaluationOpen} onOpenChange={setEvaluationOpen}> + <DialogContent> + <DialogHeader> + <DialogTitle>TBE Evaluation</DialogTitle> + <DialogDescription> + Enter evaluation result for {selectedSession?.sessionCode} + </DialogDescription> + </DialogHeader> + <div className="space-y-4 mt-4"> + {/* Evaluation form would go here */} + <p className="text-sm text-muted-foreground"> + Evaluation form to be implemented... + </p> + </div> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/tbe-last/validations.ts b/lib/tbe-last/validations.ts new file mode 100644 index 00000000..2f2cdd69 --- /dev/null +++ b/lib/tbe-last/validations.ts @@ -0,0 +1,37 @@ +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum,parseAsBoolean +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { TbeSession } from "@/db/schema"; + + + +export const searchParamsTBELastCache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (Rfq 테이블) + // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 + sort: getSortingStateParser<TbeSession>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + + // 6) 고급 필터 (nuqs - filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), +}) +export type GetTBELastSchema = Awaited<ReturnType<typeof searchParamsTBELastCache.parse>>; + |
