summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/gen-service.ts399
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-columns.tsx52
-rw-r--r--lib/mail/templates/rfq-due-date-change.hbs235
-rw-r--r--lib/mail/templates/rfq-invitation.hbs301
-rw-r--r--lib/mail/templates/rfq-resend.hbs364
-rw-r--r--lib/rfq-last/due-date-edit-button.tsx216
-rw-r--r--lib/rfq-last/service.ts1865
-rw-r--r--lib/rfq-last/table/rfq-attachments-dialog.tsx18
-rw-r--r--lib/rfq-last/table/rfq-items-dialog.tsx4
-rw-r--r--lib/rfq-last/vendor-response/editor/attachments-upload.tsx466
-rw-r--r--lib/rfq-last/vendor-response/editor/commercial-terms-form.tsx713
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx449
-rw-r--r--lib/rfq-last/vendor-response/editor/rfq-info-header.tsx213
-rw-r--r--lib/rfq-last/vendor-response/editor/vendor-response-editor.tsx477
-rw-r--r--lib/rfq-last/vendor-response/participation-dialog.tsx230
-rw-r--r--lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx407
-rw-r--r--lib/rfq-last/vendor-response/rfq-items-dialog.tsx354
-rw-r--r--lib/rfq-last/vendor-response/service.ts483
-rw-r--r--lib/rfq-last/vendor-response/validations.ts42
-rw-r--r--lib/rfq-last/vendor-response/vendor-quotations-table-columns.tsx514
-rw-r--r--lib/rfq-last/vendor-response/vendor-quotations-table.tsx171
-rw-r--r--lib/rfq-last/vendor/add-vendor-dialog.tsx438
-rw-r--r--lib/rfq-last/vendor/delete-vendor-dialog.tsx124
-rw-r--r--lib/rfq-last/vendor/edit-contract-dialog.tsx237
-rw-r--r--lib/rfq-last/vendor/rfq-vendor-table.tsx463
-rw-r--r--lib/rfq-last/vendor/send-rfq-dialog.tsx739
-rw-r--r--lib/rfq-last/vendor/vendor-detail-dialog.tsx695
-rw-r--r--lib/tbe-last/service.ts247
-rw-r--r--lib/tbe-last/table/tbe-last-table-columns.tsx376
-rw-r--r--lib/tbe-last/table/tbe-last-table.tsx419
-rw-r--r--lib/tbe-last/validations.ts37
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>>;
+