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