summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/techsales-rfq/actions.ts30
-rw-r--r--lib/techsales-rfq/repository.ts29
-rw-r--r--lib/techsales-rfq/service.ts1043
-rw-r--r--lib/techsales-rfq/table/create-rfq-hull-dialog.tsx8
-rw-r--r--lib/techsales-rfq/table/create-rfq-ship-dialog.tsx4
-rw-r--r--lib/techsales-rfq/table/create-rfq-top-dialog.tsx21
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx10
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx312
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx101
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx178
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx341
-rw-r--r--lib/techsales-rfq/table/rfq-items-view-dialog.tsx4
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx99
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx22
-rw-r--r--lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx231
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx183
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx228
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-editor.tsx559
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-item-editor.tsx664
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx32
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx22
21 files changed, 1821 insertions, 2300 deletions
diff --git a/lib/techsales-rfq/actions.ts b/lib/techsales-rfq/actions.ts
index 1171271f..5d5d5118 100644
--- a/lib/techsales-rfq/actions.ts
+++ b/lib/techsales-rfq/actions.ts
@@ -2,12 +2,9 @@
import { revalidatePath } from "next/cache"
import {
- acceptTechSalesVendorQuotation,
- rejectTechSalesVendorQuotation
+ acceptTechSalesVendorQuotation
} from "./service"
-// ... existing code ...
-
/**
* 기술영업 벤더 견적 승인 (벤더 선택) Server Action
*/
@@ -32,28 +29,3 @@ export async function acceptTechSalesVendorQuotationAction(quotationId: number)
}
}
}
-
-// /**
-// * 기술영업 벤더 견적 거절 Server Action
-// */
-// export async function rejectTechSalesVendorQuotationAction(quotationId: number, rejectionReason?: string) {
-// try {
-// const result = await rejectTechSalesVendorQuotation(quotationId, rejectionReason)
-
-// if (result.success) {
-// // 관련 페이지들 재검증
-// revalidatePath("/evcp/budgetary-tech-sales-ship")
-// revalidatePath("/partners/techsales")
-
-// return { success: true, message: "견적이 성공적으로 거절되었습니다" }
-// } else {
-// return { success: false, error: result.error }
-// }
-// } catch (error) {
-// console.error("견적 거절 액션 오류:", error)
-// return {
-// success: false,
-// error: error instanceof Error ? error.message : "견적 거절에 실패했습니다"
-// }
-// }
-// } \ No newline at end of file
diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts
index e9ad3925..1aaf4b3d 100644
--- a/lib/techsales-rfq/repository.ts
+++ b/lib/techsales-rfq/repository.ts
@@ -117,11 +117,24 @@ export async function selectTechSalesRfqsWithJoin(
projMsrm: biddingProjects.projMsrm,
ptypeNm: biddingProjects.ptypeNm,
- // 첨부파일 개수
+ // 첨부파일 개수 (타입별로 분리)
attachmentCount: sql<number>`(
SELECT COUNT(*)
FROM tech_sales_attachments
WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
+ )`,
+ hasTbeAttachments: sql<boolean>`(
+ SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'TBE_RESULT'
+ )`,
+ hasCbeAttachments: sql<boolean>`(
+ SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'CBE_RESULT'
)`,
// 벤더 견적 개수
@@ -258,6 +271,20 @@ export async function selectTechSalesVendorQuotationsWithJoin(
WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
)`,
+ // 견적서 첨부파일 개수
+ quotationAttachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotation_attachments
+ WHERE tech_sales_vendor_quotation_attachments.quotation_id = ${techSalesVendorQuotations.id}
+ )`,
+
+ // RFQ 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 96d6a3c9..25e1f379 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -5,7 +5,9 @@ import db from "@/db/db";
import {
techSalesRfqs,
techSalesVendorQuotations,
+ techSalesVendorQuotationRevisions,
techSalesAttachments,
+ techSalesVendorQuotationAttachments,
users,
techSalesRfqComments,
techSalesRfqItems,
@@ -30,6 +32,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { sendEmail } from "../mail/sendEmail";
import { formatDate } from "../utils";
import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
// 정렬 타입 정의
// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
@@ -79,16 +82,6 @@ async function generateRfqCodes(tx: any, count: number, year?: number): Promise<
return codes;
}
-/**
- * 기술영업 조선 RFQ 생성 액션
- *
- * 받을 파라미터 (생성시 입력하는 것)
- * 1. RFQ 관련
- * 2. 프로젝트 관련
- * 3. 자재 관련 (자재그룹)
- *
- * 나머지 벤더, 첨부파일 등은 생성 이후 처리
- */
/**
* 직접 조인을 사용하여 RFQ 데이터 조회하는 함수
@@ -309,8 +302,29 @@ export async function getTechSalesVendorQuotationsWithJoin(input: {
limit: input.perPage,
});
+ // 각 견적서의 첨부파일 정보 조회
+ const dataWithAttachments = await Promise.all(
+ data.map(async (quotation) => {
+ const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
+ return {
+ ...quotation,
+ quotationAttachments: attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ filePath: att.filePath,
+ description: att.description,
+ }))
+ };
+ })
+ );
+
const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere);
- return { data, total };
+ return { data: dataWithAttachments, total };
});
const pageCount = Math.ceil(total / input.perPage);
@@ -414,160 +428,6 @@ export async function getTechSalesDashboardWithJoin(input: {
}
}
-
-
-/**
- * 기술영업 RFQ에서 벤더 제거 (Draft 상태 체크 포함)
- */
-export async function removeVendorFromTechSalesRfq(input: {
- rfqId: number;
- vendorId: number;
-}) {
- unstable_noStore();
- try {
- // 먼저 해당 벤더의 견적서 상태 확인
- const existingQuotation = await db
- .select()
- .from(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- )
- .limit(1);
-
- if (existingQuotation.length === 0) {
- return {
- data: null,
- error: "해당 벤더가 이 RFQ에 존재하지 않습니다."
- };
- }
-
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation[0].status !== "Draft") {
- return {
- data: null,
- error: "Draft 상태의 벤더만 삭제할 수 있습니다."
- };
- }
-
- // 해당 벤더의 견적서 삭제
- const deletedQuotations = await db
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- )
- .returning();
-
- // RFQ 타입 조회 및 캐시 무효화
- const rfqForCache = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidateTag(`vendor-${input.vendorId}-quotations`);
- revalidatePath(getTechSalesRevalidationPath(rfqForCache?.rfqType || "SHIP"));
-
- return { data: deletedQuotations[0], error: null };
- } catch (err) {
- console.error("Error removing vendor from RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ에서 여러 벤더 일괄 제거 (Draft 상태 체크 포함)
- */
-export async function removeVendorsFromTechSalesRfq(input: {
- rfqId: number;
- vendorIds: number[];
-}) {
- unstable_noStore();
- try {
- const results: typeof techSalesVendorQuotations.$inferSelect[] = [];
- const errors: string[] = [];
-
- // 트랜잭션으로 처리
- await db.transaction(async (tx) => {
- for (const vendorId of input.vendorIds) {
- try {
- // 먼저 해당 벤더의 견적서 상태 확인
- const existingQuotation = await tx
- .select()
- .from(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- )
- .limit(1);
-
- if (existingQuotation.length === 0) {
- errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`);
- continue;
- }
-
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation[0].status !== "Draft") {
- errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`);
- continue;
- }
-
- // 해당 벤더의 견적서 삭제
- const deletedQuotations = await tx
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- )
- .returning();
-
- if (deletedQuotations.length > 0) {
- results.push(deletedQuotations[0]);
- }
- } catch (vendorError) {
- console.error(`Error removing vendor ${vendorId}:`, vendorError);
- errors.push(`벤더 ID ${vendorId} 삭제 중 오류가 발생했습니다.`);
- }
- }
- });
-
- // RFQ 타입 조회 및 캐시 무효화
- const rfqForCache2 = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath(getTechSalesRevalidationPath(rfqForCache2?.rfqType || "SHIP"));
-
- // 벤더별 캐시도 무효화
- for (const vendorId of input.vendorIds) {
- revalidateTag(`vendor-${vendorId}-quotations`);
- }
-
- return {
- data: results,
- error: errors.length > 0 ? errors.join(", ") : null,
- successCount: results.length,
- errorCount: errors.length
- };
- } catch (err) {
- console.error("Error removing vendors from RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
/**
* 특정 RFQ의 벤더 목록 조회
*/
@@ -716,6 +576,19 @@ export async function sendTechSalesRfqToVendors(input: {
.set(updateData)
.where(eq(techSalesRfqs.id, input.rfqId));
+ // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경
+ for (const quotation of vendorQuotations) {
+ if (quotation.status === "Assigned") {
+ await tx.update(techSalesVendorQuotations)
+ .set({
+ status: "Draft",
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotation.id));
+ }
+ }
+
// 2. 각 벤더에 대해 이메일 발송 처리
for (const quotation of vendorQuotations) {
if (!quotation.vendorId || !quotation.vendor) continue;
@@ -847,6 +720,12 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
const itemsResult = await getTechSalesRfqItems(quotation.rfqId);
const items = itemsResult.data || [];
+ // 견적서 첨부파일 조회
+ const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
// 기존 구조와 호환되도록 데이터 재구성
const formattedQuotation = {
id: quotation.id,
@@ -911,7 +790,16 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
country: quotation.vendorCountry,
email: quotation.vendorEmail,
phone: quotation.vendorPhone,
- }
+ },
+
+ // 첨부파일 정보
+ quotationAttachments: quotationAttachments.map(attachment => ({
+ id: attachment.id,
+ fileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ description: attachment.description,
+ }))
};
return { data: formattedQuotation, error: null };
@@ -922,7 +810,8 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
}
/**
- * 기술영업 벤더 견적서 업데이트 (임시저장)
+ * 기술영업 벤더 견적서 업데이트 (임시저장),
+ * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함.
*/
export async function updateTechSalesVendorQuotation(data: {
id: number
@@ -931,46 +820,78 @@ export async function updateTechSalesVendorQuotation(data: {
validUntil: Date
remark?: string
updatedBy: number
+ changeReason?: string
}) {
try {
- // 현재 견적서 상태 및 벤더 ID 확인
- const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- columns: {
- status: true,
- vendorId: true,
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
}
- });
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
+ // Accepted나 Rejected 상태가 아니면 수정 가능
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." };
+ }
- // Draft 또는 Revised 상태에서만 수정 가능
- if (!["Draft", "Revised"].includes(currentQuotation.status)) {
- return { data: null, error: "현재 상태에서는 견적서를 수정할 수 없습니다." };
- }
+ // 실제 변경사항이 있는지 확인
+ const hasChanges =
+ currentQuotation.currency !== data.currency ||
+ currentQuotation.totalPrice !== data.totalPrice ||
+ currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ currentQuotation.remark !== (data.remark || null);
- const result = await db
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning()
+ if (!hasChanges) {
+ return { data: currentQuotation, error: null };
+ }
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations")
- revalidatePath(`/partners/techsales/rfq-ship/${data.id}`)
+ // 현재 버전을 revision history에 저장
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: data.changeReason || "견적서 수정",
+ revisedBy: data.updatedBy,
+ });
- return { data: result[0], error: null }
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: (currentQuotation.quotationVersion || 1) + 1,
+ status: "Revised", // 수정된 상태로 변경
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
+
+ return { data: result[0], error: null };
+ });
} catch (error) {
- console.error("Error updating tech sales vendor quotation:", error)
- return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" }
+ console.error("Error updating tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship/${data.id}`);
}
}
@@ -983,63 +904,134 @@ export async function submitTechSalesVendorQuotation(data: {
totalPrice: string
validUntil: Date
remark?: string
+ attachments?: Array<{
+ fileName: string
+ filePath: string
+ fileSize: number
+ }>
updatedBy: number
}) {
try {
- // 현재 견적서 상태 확인
- const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- columns: {
- status: true,
- vendorId: true,
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
}
- });
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
+ // Rejected 상태에서는 제출 불가
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "거절된 견적서는 제출할 수 없습니다." };
+ }
+
+ // // 실제 변경사항이 있는지 확인
+ // const hasChanges =
+ // currentQuotation.currency !== data.currency ||
+ // currentQuotation.totalPrice !== data.totalPrice ||
+ // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ // currentQuotation.remark !== (data.remark || null);
+
+ // // 변경사항이 있거나 처음 제출하는 경우 revision 저장
+ // if (hasChanges || currentQuotation.status === "Draft") {
+ // await tx.insert(techSalesVendorQuotationRevisions).values({
+ // quotationId: data.id,
+ // version: currentQuotation.quotationVersion || 1,
+ // snapshot: {
+ // currency: currentQuotation.currency,
+ // totalPrice: currentQuotation.totalPrice,
+ // validUntil: currentQuotation.validUntil,
+ // remark: currentQuotation.remark,
+ // status: currentQuotation.status,
+ // quotationVersion: currentQuotation.quotationVersion,
+ // submittedAt: currentQuotation.submittedAt,
+ // acceptedAt: currentQuotation.acceptedAt,
+ // updatedAt: currentQuotation.updatedAt,
+ // },
+ // changeReason: "견적서 제출",
+ // revisedBy: data.updatedBy,
+ // });
+ // }
+
+ // 항상 revision 저장 (변경사항 여부와 관계없이)
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: "견적서 제출",
+ revisedBy: data.updatedBy,
+ });
- // Draft 또는 Revised 상태에서만 제출 가능
- if (!["Draft", "Revised"].includes(currentQuotation.status)) {
- return { data: null, error: "현재 상태에서는 견적서를 제출할 수 없습니다." };
- }
+ // 새로운 버전 번호 계산 (항상 1 증가)
+ const newRevisionId = (currentQuotation.quotationVersion || 1) + 1;
- const result = await db
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- status: "Submitted",
- submittedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning()
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: newRevisionId,
+ status: "Submitted",
+ submittedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
- // 메일 발송 (백그라운드에서 실행)
- if (result[0]) {
- // 벤더에게 견적 제출 확인 메일 발송
- sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
- console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
- });
+ // 첨부파일 처리 (새로운 revisionId 사용)
+ if (data.attachments && data.attachments.length > 0) {
+ for (const attachment of data.attachments) {
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId: data.id,
+ revisionId: newRevisionId, // 새로운 리비전 ID 사용
+ fileName: attachment.fileName,
+ originalFileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ fileType: attachment.fileName.split('.').pop() || 'unknown',
+ uploadedBy: data.updatedBy,
+ isVendorUpload: true,
+ });
+ }
+ }
- // 담당자에게 견적 접수 알림 메일 발송
- sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
- console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
- });
- }
+ // 메일 발송 (백그라운드에서 실행)
+ if (result[0]) {
+ // 벤더에게 견적 제출 확인 메일 발송
+ sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
+ console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
+ });
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations")
- revalidateTag(`vendor-${currentQuotation.vendorId}-quotations`)
- revalidatePath(`/partners/techsales/rfq-ship/${data.id}`)
+ // 담당자에게 견적 접수 알림 메일 발송
+ sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
+ console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
+ });
+ }
- return { data: result[0], error: null }
+ return { data: result[0], error: null };
+ });
} catch (error) {
- console.error("Error submitting tech sales vendor quotation:", error)
- return { data: null, error: "견적서 제출 중 오류가 발생했습니다" }
+ console.error("Error submitting tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 제출 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship`);
}
}
@@ -1095,14 +1087,17 @@ export async function getVendorQuotations(input: {
const offset = (page - 1) * perPage;
const limit = perPage;
- // 기본 조건: 해당 벤더의 견적서만 조회
+ // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외)
const vendorIdNum = parseInt(vendorId);
if (isNaN(vendorIdNum)) {
console.error('❌ [getVendorQuotations] Invalid vendorId:', vendorId);
return { data: [], pageCount: 0, total: 0 };
}
- const baseConditions = [eq(techSalesVendorQuotations.vendorId, vendorIdNum)];
+ const baseConditions = [
+ eq(techSalesVendorQuotations.vendorId, vendorIdNum),
+ sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외
+ ];
// rfqType 필터링 추가
if (input.rfqType) {
@@ -1210,9 +1205,13 @@ export async function getVendorQuotations(input: {
description: techSalesRfqs.description,
// 프로젝트 정보 (직접 조인)
projNm: biddingProjects.projNm,
- // 아이템 정보 추가 (임시로 description 사용)
- // itemName: techSalesRfqs.description,
- // 첨부파일 개수
+ // 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+ // RFQ 첨부파일 개수
attachmentCount: sql<number>`(
SELECT COUNT(*)
FROM tech_sales_attachments
@@ -1221,6 +1220,7 @@ export async function getVendorQuotations(input: {
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(techSalesAttachments, eq(techSalesRfqs.id, techSalesAttachments.techSalesRfqId))
.leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
.where(finalWhere)
.orderBy(...orderBy)
@@ -1256,48 +1256,6 @@ export async function getVendorQuotations(input: {
}
/**
- * 벤더용 기술영업 견적서 상태별 개수 조회
- */
-export async function getQuotationStatusCounts(vendorId: string, rfqType?: "SHIP" | "TOP" | "HULL") {
- return unstable_cache(
- async () => {
- try {
- const query = db
- .select({
- status: techSalesVendorQuotations.status,
- count: sql<number>`count(*)`,
- })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id));
-
- // 조건 설정
- const conditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))];
- if (rfqType) {
- conditions.push(eq(techSalesRfqs.rfqType, rfqType));
- }
-
- const result = await query
- .where(and(...conditions))
- .groupBy(techSalesVendorQuotations.status);
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error fetching quotation status counts:", err);
- return { data: null, error: getErrorMessage(err) };
- }
- },
- [vendorId], // 캐싱 키
- {
- revalidate: 60, // 1분간 캐시
- tags: [
- "techSalesVendorQuotations",
- `vendor-${vendorId}-quotations`
- ],
- }
- )();
-}
-
-/**
* 기술영업 벤더 견적 승인 (벤더 선택)
*/
export async function acceptTechSalesVendorQuotation(quotationId: number) {
@@ -1358,6 +1316,9 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
for (const vendorQuotation of allVendorsInRfq) {
revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`);
}
+ revalidatePath("/evcp/budgetary-tech-sales-ship")
+ revalidatePath("/partners/techsales")
+
return { success: true, data: result }
} catch (error) {
@@ -1370,7 +1331,7 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
}
/**
- * 기술영업 RFQ 첨부파일 생성 (파일 업로드)
+ * 기술영업 RFQ 첨부파일 생성 (파일 업로드), 사용x
*/
export async function createTechSalesRfqAttachments(params: {
techSalesRfqId: number
@@ -1415,8 +1376,7 @@ export async function createTechSalesRfqAttachments(params: {
await fs.mkdir(rfqDir, { recursive: true });
for (const file of files) {
- const ab = await file.arrayBuffer();
- const buffer = Buffer.from(ab);
+ const decryptedBuffer = await decryptWithServerAction(file);
// 고유 파일명 생성
const uniqueName = `${randomUUID()}-${file.name}`;
@@ -1424,7 +1384,7 @@ export async function createTechSalesRfqAttachments(params: {
const absolutePath = path.join(process.cwd(), "public", relativePath);
// 파일 저장
- await fs.writeFile(absolutePath, buffer);
+ await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer));
// DB에 첨부파일 레코드 생성
const [newAttachment] = await tx.insert(techSalesAttachments).values({
@@ -1488,6 +1448,39 @@ export async function getTechSalesRfqAttachments(techSalesRfqId: number) {
}
/**
+ * RFQ 첨부파일 타입별 조회
+ */
+export async function getTechSalesRfqAttachmentsByType(
+ techSalesRfqId: number,
+ attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"
+) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: and(
+ eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ eq(techSalesAttachments.attachmentType, attachmentType)
+ ),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
* 기술영업 RFQ 첨부파일 삭제
*/
export async function deleteTechSalesRfqAttachment(attachmentId: number) {
@@ -1561,7 +1554,7 @@ export async function deleteTechSalesRfqAttachment(attachmentId: number) {
*/
export async function processTechSalesRfqAttachments(params: {
techSalesRfqId: number
- newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"; description?: string }[]
+ newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[]
deleteAttachmentIds: number[]
createdBy: number
}) {
@@ -1623,16 +1616,16 @@ export async function processTechSalesRfqAttachments(params: {
await fs.mkdir(rfqDir, { recursive: true });
for (const { file, attachmentType, description } of newFiles) {
- const ab = await file.arrayBuffer();
- const buffer = Buffer.from(ab);
+ // 파일 복호화
+ const decryptedBuffer = await decryptWithServerAction(file);
// 고유 파일명 생성
const uniqueName = `${randomUUID()}-${file.name}`;
const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName);
const absolutePath = path.join(process.cwd(), "public", relativePath);
- // 파일 저장
- await fs.writeFile(absolutePath, buffer);
+ // 복호화된 파일 저장
+ await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer));
// DB에 첨부파일 레코드 생성
const [newAttachment] = await tx.insert(techSalesAttachments).values({
@@ -2213,6 +2206,8 @@ export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: numb
}
}
+// ==================== RFQ 조선/해양 관련 ====================
+
/**
* 기술영업 조선 RFQ 생성 (1:N 관계)
*/
@@ -2223,9 +2218,7 @@ export async function createTechSalesShipRfq(input: {
description?: string;
createdBy: number;
}) {
- unstable_noStore();
- console.log('🔍 createTechSalesShipRfq 호출됨:', input);
-
+ unstable_noStore();
try {
return await db.transaction(async (tx) => {
// 프로젝트 정보 조회 (유효성 검증)
@@ -2474,46 +2467,7 @@ export async function getTechSalesHullVendorQuotationsWithJoin(input: {
return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" });
}
-/**
- * 조선 대시보드 전용 조회 함수
- */
-export async function getTechSalesShipDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesDashboardWithJoin({ ...input, rfqType: "SHIP" });
-}
-
-/**
- * 해양 TOP 대시보드 전용 조회 함수
- */
-export async function getTechSalesTopDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesDashboardWithJoin({ ...input, rfqType: "TOP" });
-}
-
-/**
- * 해양 HULL 대시보드 전용 조회 함수
- */
-export async function getTechSalesHullDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesDashboardWithJoin({ ...input, rfqType: "HULL" });
-}
-
-/**
+/**
* 기술영업 RFQ의 아이템 목록 조회
*/
export async function getTechSalesRfqItems(rfqId: number) {
@@ -2700,53 +2654,6 @@ export async function getTechSalesRfqCandidateVendors(rfqId: number) {
}
/**
- * 기술영업 RFQ에 벤더 추가 (techVendors 기반)
- */
-export async function addTechVendorToTechSalesRfq(input: {
- rfqId: number;
- vendorId: number;
- createdBy: number;
-}) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- // 벤더가 이미 추가되어 있는지 확인
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- });
-
- if (existingQuotation) {
- return { data: null, error: "이미 추가된 벤더입니다." };
- }
-
- // 새로운 견적서 레코드 생성
- const [quotation] = await tx
- .insert(techSalesVendorQuotations)
- .values({
- rfqId: input.rfqId,
- vendorId: input.vendorId,
- status: "Draft",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesVendorQuotations.id });
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
-
- return { data: quotation, error: null };
- });
- } catch (err) {
- console.error("Error adding tech vendor to RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
* RFQ 타입에 따른 캐시 무효화 경로 반환
*/
function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string {
@@ -2764,6 +2671,7 @@ function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string
/**
* 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반)
+ * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성
*/
export async function addTechVendorsToTechSalesRfq(input: {
rfqId: number;
@@ -2783,7 +2691,7 @@ export async function addTechVendorsToTechSalesRfq(input: {
columns: {
id: true,
status: true,
- rfqType: true
+ rfqType: true,
}
});
@@ -2791,10 +2699,10 @@ export async function addTechVendorsToTechSalesRfq(input: {
throw new Error("RFQ를 찾을 수 없습니다");
}
- // 2. 각 벤더에 대해 처리
+ // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인)
for (const vendorId of input.vendorIds) {
try {
- // 벤더가 이미 추가되어 있는지 확인
+ // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인)
const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
where: and(
eq(techSalesVendorQuotations.rfqId, input.rfqId),
@@ -2807,19 +2715,30 @@ export async function addTechVendorsToTechSalesRfq(input: {
continue;
}
- // 새로운 견적서 레코드 생성
+ // 벤더가 실제로 존재하는지 확인
+ const vendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.id, vendorId),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (!vendor) {
+ errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`);
+ continue;
+ }
+
+ // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성
const [quotation] = await tx
.insert(techSalesVendorQuotations)
.values({
rfqId: input.rfqId,
vendorId: vendorId,
- status: "Draft",
+ status: "Assigned", // Draft가 아닌 Assigned 상태로 생성
createdBy: input.createdBy,
updatedBy: input.createdBy,
})
.returning({ id: techSalesVendorQuotations.id });
-
- results.push(quotation);
+
+ results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName });
} catch (vendorError) {
console.error(`Error adding vendor ${vendorId}:`, vendorError);
errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`);
@@ -2843,11 +2762,6 @@ export async function addTechVendorsToTechSalesRfq(input: {
revalidateTag(`techSalesRfq-${input.rfqId}`);
revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
- // 벤더별 캐시도 무효화
- for (const vendorId of input.vendorIds) {
- revalidateTag(`vendor-${vendorId}-quotations`);
- }
-
return {
data: results,
error: errors.length > 0 ? errors.join(", ") : null,
@@ -2921,9 +2835,9 @@ export async function removeTechVendorFromTechSalesRfq(input: {
return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." };
}
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Draft") {
- return { data: null, error: "Draft 상태의 벤더만 삭제할 수 있습니다." };
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." };
}
// 해당 벤더의 견적서 삭제
@@ -2977,9 +2891,9 @@ export async function removeTechVendorsFromTechSalesRfq(input: {
continue;
}
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Draft") {
- errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`);
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`);
continue;
}
@@ -3060,6 +2974,242 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType
}
}
+
+/**
+ * 벤더 견적서 거절 처리 (벤더가 직접 거절)
+ */
+export async function rejectTechSalesVendorQuotations(input: {
+ quotationIds: number[];
+ rejectionReason?: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.");
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 견적서들이 존재하고 벤더가 권한이 있는지 확인
+ const quotations = await tx
+ .select({
+ id: techSalesVendorQuotations.id,
+ status: techSalesVendorQuotations.status,
+ vendorId: techSalesVendorQuotations.vendorId,
+ })
+ .from(techSalesVendorQuotations)
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ if (quotations.length !== input.quotationIds.length) {
+ throw new Error("일부 견적서를 찾을 수 없습니다.");
+ }
+
+ // 이미 거절된 견적서가 있는지 확인
+ const alreadyRejected = quotations.filter(q => q.status === "Rejected");
+ if (alreadyRejected.length > 0) {
+ throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
+ }
+
+ // 승인된 견적서가 있는지 확인
+ const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
+ if (alreadyAccepted.length > 0) {
+ throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
+ }
+
+ // 견적서 상태를 거절로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Rejected",
+ rejectionReason: input.rejectionReason || null,
+ updatedBy: parseInt(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ return { success: true, updatedCount: quotations.length };
+ });
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath("/partners/techsales/rfq-ship", "page");
+ return {
+ success: true,
+ message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
+ data: result
+ };
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error)
+ };
+ }
+}
+
+// ==================== Revision 관련 ====================
+
+/**
+ * 견적서 revision 히스토리 조회
+ */
+export async function getTechSalesVendorQuotationRevisions(quotationId: number) {
+ try {
+ const revisions = await db
+ .select({
+ id: techSalesVendorQuotationRevisions.id,
+ version: techSalesVendorQuotationRevisions.version,
+ snapshot: techSalesVendorQuotationRevisions.snapshot,
+ changeReason: techSalesVendorQuotationRevisions.changeReason,
+ revisionNote: techSalesVendorQuotationRevisions.revisionNote,
+ revisedBy: techSalesVendorQuotationRevisions.revisedBy,
+ revisedAt: techSalesVendorQuotationRevisions.revisedAt,
+ // 수정자 정보 조인
+ revisedByName: users.name,
+ })
+ .from(techSalesVendorQuotationRevisions)
+ .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id))
+ .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationRevisions.version));
+
+ return { data: revisions, error: null };
+ } catch (error) {
+ console.error("견적서 revision 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함)
+ */
+export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) {
+ try {
+ // 먼저 현재 견적서 조회
+ const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우)
+ }
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회
+ const [revisionsResult, currentAttachments] = await Promise.all([
+ getTechSalesVendorQuotationRevisions(quotationId),
+ getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0)
+ ]);
+
+ // 현재 견적서에 첨부파일 정보 추가
+ const currentWithAttachments = {
+ ...currentQuotation,
+ attachments: currentAttachments.data || []
+ };
+
+ // 각 리비전의 첨부파일 정보 추가
+ const revisionsWithAttachments = await Promise.all(
+ (revisionsResult.data || []).map(async (revision) => {
+ const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version);
+ return {
+ ...revision,
+ attachments: attachmentsResult.data || []
+ };
+ })
+ );
+
+ return {
+ data: {
+ current: currentWithAttachments,
+ revisions: revisionsWithAttachments
+ },
+ error: null
+ };
+ } catch (error) {
+ console.error("견적서 전체 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬)
+ */
+export async function getTechSalesVendorQuotationAttachments(quotationId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." };
+ }
+ },
+ [`quotation-attachments-${quotationId}`],
+ {
+ revalidate: 60,
+ tags: [`quotation-${quotationId}`, "quotation-attachments"],
+ }
+ )();
+}
+
+/**
+ * 특정 리비전의 견적서 첨부파일 조회
+ */
+export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.revisionId, revisionId)
+ ))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("리비전별 견적서 첨부파일 조회 오류:", error);
+ return { error: "첨부파일 조회 중 오류가 발생했습니다." };
+ }
+}
+
+
+// ==================== Project AVL 관련 ====================
+
/**
* Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함)
*/
@@ -3076,9 +3226,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
try {
const offset = (input.page - 1) * input.perPage;
- // 기본 WHERE 조건: status = 'Accepted'만 조회
+ // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
const baseConditions = [
- eq(techSalesVendorQuotations.status, 'Accepted')
+ eq(techSalesVendorQuotations.status, 'Accepted'),
+ sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
];
// 검색 조건 추가
@@ -3126,10 +3277,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
// 필터 조건 추가
const filterConditions = [];
if (input.filters?.length) {
- const { filterWhere, joinOperator } = filterColumns({
+ const filterWhere = filterColumns({
table: techSalesVendorQuotations,
filters: input.filters,
- joinOperator: input.joinOperator ?? "and",
+ joinOperator: "and",
});
if (filterWhere) {
filterConditions.push(filterWhere);
@@ -3221,74 +3372,4 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
console.error("getAcceptedTechSalesVendorQuotations 오류:", error);
throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`);
}
-}
-
-/**
- * 벤더 견적서 거절 처리 (벤더가 직접 거절)
- */
-export async function rejectTechSalesVendorQuotations(input: {
- quotationIds: number[];
- rejectionReason?: string;
-}) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.");
- }
-
- const result = await db.transaction(async (tx) => {
- // 견적서들이 존재하고 벤더가 권한이 있는지 확인
- const quotations = await tx
- .select({
- id: techSalesVendorQuotations.id,
- status: techSalesVendorQuotations.status,
- vendorId: techSalesVendorQuotations.vendorId,
- })
- .from(techSalesVendorQuotations)
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- if (quotations.length !== input.quotationIds.length) {
- throw new Error("일부 견적서를 찾을 수 없습니다.");
- }
-
- // 이미 거절된 견적서가 있는지 확인
- const alreadyRejected = quotations.filter(q => q.status === "Rejected");
- if (alreadyRejected.length > 0) {
- throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
- }
-
- // 승인된 견적서가 있는지 확인
- const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
- if (alreadyAccepted.length > 0) {
- throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
- }
-
- // 견적서 상태를 거절로 변경
- await tx
- .update(techSalesVendorQuotations)
- .set({
- status: "Rejected",
- rejectionReason: input.rejectionReason || null,
- updatedBy: parseInt(session.user.id),
- updatedAt: new Date(),
- })
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- return { success: true, updatedCount: quotations.length };
- });
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidatePath("/partners/techsales/rfq-ship", "page");
- return {
- success: true,
- message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
- data: result
- };
- } catch (error) {
- console.error("견적서 거절 오류:", error);
- return {
- success: false,
- error: getErrorMessage(error)
- };
- }
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
index 4ba98cc7..7bbbfa75 100644
--- a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
@@ -362,18 +362,16 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
)}
/>
- <Separator className="my-4" />
-
{/* RFQ 설명 */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
- <FormLabel>RFQ 설명</FormLabel>
+ <FormLabel>RFQ Title</FormLabel>
<FormControl>
<Input
- placeholder="RFQ 설명을 입력하세요 (선택사항)"
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
{...field}
/>
</FormControl>
@@ -381,9 +379,7 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
</FormItem>
)}
/>
-
<Separator className="my-4" />
-
{/* 마감일 설정 */}
<FormField
control={form.control}
diff --git a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
index 8a66f26e..b616f526 100644
--- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
@@ -385,10 +385,10 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
name="description"
render={({ field }) => (
<FormItem>
- <FormLabel>RFQ 설명</FormLabel>
+ <FormLabel>RFQ Title</FormLabel>
<FormControl>
<Input
- placeholder="RFQ 설명을 입력하세요 (선택사항)"
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
{...field}
/>
</FormControl>
diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
index 70f56ebd..6536e230 100644
--- a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
@@ -3,7 +3,6 @@
import * as React from "react"
import { toast } from "sonner"
import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
-import { Input } from "@/components/ui/input"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { CalendarIcon } from "lucide-react"
@@ -43,6 +42,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
+import { Input } from "@/components/ui/input"
// 공종 타입 import
import {
@@ -354,7 +354,24 @@ export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
/>
<Separator className="my-4" />
-
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <Separator className="my-4" />
{/* 마감일 설정 */}
<FormField
control={form.control}
diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
index 3574111f..8f2fe948 100644
--- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
@@ -29,6 +29,8 @@ type VendorFormValues = z.infer<typeof vendorFormSchema>
type TechSalesRfq = {
id: number
rfqCode: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm: string | null // 프로젝트 타입명 추가
status: string
[key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
}
@@ -118,10 +120,8 @@ export function AddVendorDialog({
setIsSearching(true)
try {
// 선택된 RFQ의 타입을 기반으로 벤더 검색
- const rfqType = selectedRfq?.rfqCode?.includes("SHIP") ? "SHIP" :
- selectedRfq?.rfqCode?.includes("TOP") ? "TOP" :
- selectedRfq?.rfqCode?.includes("HULL") ? "HULL" : undefined;
-
+ const rfqType = selectedRfq?.rfqType || undefined;
+ console.log("rfqType", rfqType) // 디버깅용
const results = await searchTechVendors(term, 100, rfqType)
// 이미 추가된 벤더 제외
@@ -136,7 +136,7 @@ export function AddVendorDialog({
setIsSearching(false)
}
},
- [existingVendorIds]
+ [existingVendorIds, selectedRfq?.rfqType]
)
// 검색어 변경 시 디바운스 적용
diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
new file mode 100644
index 00000000..7832fa2b
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
@@ -0,0 +1,312 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Separator } from "@/components/ui/separator"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Clock, User, FileText, AlertCircle, Paperclip } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+import { toast } from "sonner"
+
+interface QuotationAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface QuotationSnapshot {
+ currency: string | null
+ totalPrice: string | null
+ validUntil: Date | null
+ remark: string | null
+ status: string | null
+ quotationVersion: number | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ updatedAt: Date | null
+}
+
+interface QuotationRevision {
+ id: number
+ version: number
+ snapshot: QuotationSnapshot
+ changeReason: string | null
+ revisionNote: string | null
+ revisedBy: number | null
+ revisedAt: Date
+ revisedByName: string | null
+ attachments: QuotationAttachment[]
+}
+
+interface QuotationHistoryData {
+ current: {
+ id: number
+ currency: string | null
+ totalPrice: string | null
+ validUntil: Date | null
+ remark: string | null
+ status: string
+ quotationVersion: number | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ updatedAt: Date | null
+ attachments: QuotationAttachment[]
+ }
+ revisions: QuotationRevision[]
+}
+
+interface QuotationHistoryDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ quotationId: number | null
+}
+
+const statusConfig = {
+ "Draft": { label: "초안", color: "bg-yellow-100 text-yellow-800" },
+ "Submitted": { label: "제출됨", color: "bg-blue-100 text-blue-800" },
+ "Revised": { label: "수정됨", color: "bg-purple-100 text-purple-800" },
+ "Accepted": { label: "승인됨", color: "bg-green-100 text-green-800" },
+ "Rejected": { label: "거절됨", color: "bg-red-100 text-red-800" },
+}
+
+function QuotationCard({
+ data,
+ version,
+ isCurrent = false,
+ changeReason,
+ revisedBy,
+ revisedAt,
+ attachments
+}: {
+ data: QuotationSnapshot | QuotationHistoryData["current"]
+ version: number
+ isCurrent?: boolean
+ changeReason?: string | null
+ revisedBy?: string | null
+ revisedAt?: Date
+ attachments?: QuotationAttachment[]
+}) {
+ const statusInfo = statusConfig[data.status as keyof typeof statusConfig] ||
+ { label: data.status || "알 수 없음", color: "bg-gray-100 text-gray-800" }
+
+ return (
+ <Card className={`${isCurrent ? "border-blue-500 shadow-md" : "border-gray-200"}`}>
+ <CardHeader className="pb-3">
+ <div className="flex items-center justify-between">
+ <CardTitle className="text-lg flex items-center gap-2">
+ <span>버전 {version}</span>
+ {isCurrent && <Badge variant="default">현재</Badge>}
+ </CardTitle>
+ <Badge className={statusInfo.color}>
+ {statusInfo.label}
+ </Badge>
+ </div>
+ {changeReason && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <FileText className="size-4" />
+ <span>{changeReason}</span>
+ </div>
+ )}
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">견적 금액</p>
+ <p className="text-lg font-semibold">
+ {data.totalPrice ? `${data.currency} ${Number(data.totalPrice).toLocaleString()}` : "미입력"}
+ </p>
+ </div>
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">유효 기한</p>
+ <p className="text-sm">
+ {data.validUntil ? formatDate(data.validUntil) : "미설정"}
+ </p>
+ </div>
+ </div>
+
+ {data.remark && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">비고</p>
+ <p className="text-sm bg-gray-50 p-2 rounded">{data.remark}</p>
+ </div>
+ )}
+
+ {/* 첨부파일 섹션 */}
+ {attachments && attachments.length > 0 && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
+ <Paperclip className="size-3" />
+ 첨부파일 ({attachments.length}개)
+ </p>
+ <div className="space-y-1">
+ {attachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-2 bg-gray-50 rounded text-xs">
+ <div className="flex items-center gap-2 min-w-0 flex-1">
+ <div className="min-w-0 flex-1">
+ <p className="font-medium truncate" title={attachment.originalFileName}>
+ {attachment.originalFileName}
+ </p>
+ {attachment.description && (
+ <p className="text-muted-foreground truncate" title={attachment.description}>
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ <div className="text-muted-foreground whitespace-nowrap ml-2">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
+ <div className="flex items-center gap-1">
+ <Clock className="size-3" />
+ <span>
+ {isCurrent
+ ? `수정: ${data.updatedAt ? formatDate(data.updatedAt) : "N/A"}`
+ : `변경: ${revisedAt ? formatDate(revisedAt) : "N/A"}`
+ }
+ </span>
+ </div>
+ {revisedBy && (
+ <div className="flex items-center gap-1">
+ <User className="size-3" />
+ <span>{revisedBy}</span>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
+
+export function QuotationHistoryDialog({
+ open,
+ onOpenChange,
+ quotationId
+}: QuotationHistoryDialogProps) {
+ const [data, setData] = useState<QuotationHistoryData | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+
+ useEffect(() => {
+ if (open && quotationId) {
+ loadQuotationHistory()
+ }
+ }, [open, quotationId])
+
+ const loadQuotationHistory = async () => {
+ if (!quotationId) return
+
+ try {
+ setIsLoading(true)
+ const { getTechSalesVendorQuotationWithRevisions } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationWithRevisions(quotationId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ setData(result.data as QuotationHistoryData)
+ } catch (error) {
+ console.error("견적 히스토리 로드 오류:", error)
+ toast.error("견적 히스토리를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleOpenChange = (newOpen: boolean) => {
+ onOpenChange(newOpen)
+ if (!newOpen) {
+ setData(null) // 다이얼로그 닫을 때 데이터 초기화
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className=" max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>견적서 수정 히스토리</DialogTitle>
+ <DialogDescription>
+ 견적서의 변경 이력을 확인할 수 있습니다. 최신 버전부터 순서대로 표시됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {isLoading ? (
+ <div className="space-y-4">
+ {[1, 2, 3].map((i) => (
+ <div key={i} className="space-y-3">
+ <Skeleton className="h-6 w-32" />
+ <Skeleton className="h-32 w-full" />
+ </div>
+ ))}
+ </div>
+ ) : data ? (
+ <>
+ {/* 현재 버전 */}
+ <QuotationCard
+ data={data.current}
+ version={data.current.quotationVersion || 1}
+ isCurrent={true}
+ attachments={data.current.attachments}
+ />
+
+ {/* 이전 버전들 */}
+ {data.revisions.length > 0 ? (
+ data.revisions.map((revision) => (
+ <QuotationCard
+ key={revision.id}
+ data={revision.snapshot}
+ version={revision.version}
+ changeReason={revision.changeReason}
+ revisedBy={revision.revisedByName}
+ revisedAt={revision.revisedAt}
+ attachments={revision.attachments}
+ />
+ ))
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>수정 이력이 없습니다.</p>
+ <p className="text-sm">이 견적서는 아직 수정되지 않았습니다.</p>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>견적서 정보를 불러올 수 없습니다.</p>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index 3e50a516..e921fcaa 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -5,7 +5,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table";
import { formatDate } from "@/lib/utils"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { Checkbox } from "@/components/ui/checkbox";
-import { MessageCircle, MoreHorizontal, Trash2 } from "lucide-react";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -38,6 +38,24 @@ export interface RfqDetailView {
createdAt: Date | null
updatedAt: Date | null
createdByName: string | null
+ quotationCode?: string | null
+ rfqCode?: string | null
+ quotationAttachments?: Array<{
+ id: number
+ revisionId: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+}
+
+// 견적서 정보 타입 (Sheet용)
+export interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
}
interface GetColumnsProps<TData> {
@@ -45,11 +63,15 @@ interface GetColumnsProps<TData> {
React.SetStateAction<DataTableRowAction<TData> | null>
>;
unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
+ onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
+ openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
}
export function getRfqDetailColumns({
setRowAction,
- unreadMessages = {}
+ unreadMessages = {},
+ onQuotationClick,
+ openQuotationAttachmentsSheet
}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
return [
{
@@ -66,15 +88,15 @@ export function getRfqDetailColumns({
),
cell: ({ row }) => {
const status = row.original.status;
- const isDraft = status === "Draft";
+ const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true;
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
- disabled={!isDraft}
+ disabled={!isSelectable}
aria-label="행 선택"
- className={!isDraft ? "opacity-50 cursor-not-allowed" : ""}
+ className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""}
/>
);
},
@@ -163,15 +185,31 @@ export function getRfqDetailColumns({
cell: ({ row }) => {
const value = row.getValue("totalPrice") as string | number | null;
const currency = row.getValue("currency") as string | null;
+ const quotationId = row.original.id;
if (value === null || value === undefined) return "-";
// 숫자로 변환 시도
const numValue = typeof value === 'string' ? parseFloat(value) : value;
+ const displayValue = isNaN(numValue) ? value : numValue.toLocaleString();
+
+ // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시
+ if (onQuotationClick && quotationId) {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-medium text-left justify-start hover:underline"
+ onClick={() => onQuotationClick(quotationId)}
+ title="견적 히스토리 보기"
+ >
+ {displayValue} {currency}
+ </Button>
+ );
+ }
return (
<div className="font-medium">
- {isNaN(numValue) ? value : numValue.toLocaleString()} {currency}
+ {displayValue} {currency}
</div>
);
},
@@ -182,6 +220,57 @@ export function getRfqDetailColumns({
size: 140,
},
{
+ accessorKey: "quotationAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const attachments = row.original.quotationAttachments || [];
+ const attachmentCount = attachments.length;
+
+ if (attachmentCount === 0) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={() => {
+ // 견적서 첨부파일 sheet 열기
+ if (openQuotationAttachmentsSheet) {
+ const quotation = row.original;
+ openQuotationAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ }
+ }}
+ title={
+ attachmentCount === 1
+ ? `${attachments[0].fileName} (${(attachments[0].fileSize / 1024 / 1024).toFixed(2)} MB)`
+ : `${attachmentCount}개의 첨부파일:\n${attachments.map(att => att.fileName).join('\n')}`
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "첨부파일"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
accessorKey: "currency",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="통화" />
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index f2eda8d9..1d701bd5 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -12,12 +12,14 @@ import { toast } from "sonner"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import { Loader2, UserPlus, BarChart2, Send, Trash2 } from "lucide-react"
+import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react"
import { ClientDataTable } from "@/components/client-data-table/data-table"
import { AddVendorDialog } from "./add-vendor-dialog"
import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
-import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog"
import { DeleteVendorsDialog } from "../delete-vendors-dialog"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import type { QuotationInfo } from "./rfq-detail-column"
// 기본적인 RFQ 타입 정의
interface TechSalesRfq {
@@ -30,6 +32,8 @@ interface TechSalesRfq {
rfqSendDate?: Date | null
dueDate?: Date | null
createdByName?: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm?: string | null
}
// 프로퍼티 정의
@@ -58,9 +62,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
// 읽지 않은 메시지 개수
const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
- // 견적 비교 다이얼로그 상태 관리
- const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false)
-
// 테이블 선택 상태 관리
const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
const [isSendingRfq, setIsSendingRfq] = useState(false)
@@ -69,6 +70,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
// 벤더 삭제 확인 다이얼로그 상태 추가
const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false)
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+ const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null)
+
+ // 견적서 첨부파일 sheet 상태 관리
+ const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null)
+ const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
+ const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+
// selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
@@ -108,6 +119,8 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
detailId: item.id,
rfqId: selectedRfqId,
rfqCode: selectedRfq?.rfqCode || null,
+ rfqType: selectedRfq?.rfqType || null,
+ ptypeNm: selectedRfq?.ptypeNm || null,
vendorId: item.vendorId ? Number(item.vendorId) : undefined,
})) || []
@@ -121,7 +134,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
console.error("데이터 새로고침 오류:", error)
toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
}
- }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
+ }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages])
// 벤더 추가 핸들러 메모이제이션
const handleAddVendor = useCallback(async () => {
@@ -180,6 +193,54 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
}
}, [selectedRows, selectedRfqId, handleRefreshData]);
+ // 벤더 선택 핸들러 추가
+ const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
+
+ const handleAcceptVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("선택할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (selectedRows.length > 1) {
+ toast.warning("하나의 벤더만 선택할 수 있습니다.");
+ return;
+ }
+
+ const selectedQuotation = selectedRows[0];
+ if (selectedQuotation.status !== "Submitted") {
+ toast.warning("제출된 견적서만 선택할 수 있습니다.");
+ return;
+ }
+
+ try {
+ setIsAcceptingVendors(true);
+
+ // 벤더 견적 승인 서비스 함수 호출
+ const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions");
+
+ const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id);
+
+ if (result.success) {
+ toast.success(result.message || "벤더가 성공적으로 선택되었습니다.");
+ } else {
+ toast.error(result.error || "벤더 선택 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 선택 오류:", error);
+ toast.error("벤더 선택 중 오류가 발생했습니다.");
+ } finally {
+ setIsAcceptingVendors(false);
+ }
+ }, [selectedRows, handleRefreshData]);
+
// 벤더 삭제 핸들러 메모이제이션
const handleDeleteVendors = useCallback(async () => {
if (selectedRows.length === 0) {
@@ -246,27 +307,47 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
await handleDeleteVendors();
}, [handleDeleteVendors]);
- // 견적 비교 다이얼로그 열기 핸들러 메모이제이션
- const handleOpenComparisonDialog = useCallback(() => {
- // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인
- const hasSubmittedQuotations = details.some(detail =>
- detail.status === "Submitted" // RfqDetailView의 실제 필드 사용
- );
- if (!hasSubmittedQuotations) {
- toast.warning("제출된 견적이 없습니다.");
- return;
- }
+ // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션
+ const handleOpenHistoryDialog = useCallback((quotationId: number) => {
+ setSelectedQuotationId(quotationId);
+ setHistoryDialogOpen(true);
+ }, [])
- setComparisonDialogOpen(true);
- }, [details])
+ // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션
+ const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingAttachments(true);
+ setSelectedQuotationInfo(quotationInfo);
+ setQuotationAttachmentsSheetOpen(true);
+
+ // 견적서 첨부파일 조회
+ const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service");
+ const result = await getTechSalesVendorQuotationAttachments(quotationId);
+
+ if (result.error) {
+ toast.error(result.error);
+ setQuotationAttachments([]);
+ } else {
+ setQuotationAttachments(result.data || []);
+ }
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다.");
+ setQuotationAttachments([]);
+ } finally {
+ setIsLoadingAttachments(false);
+ }
+ }, [])
// 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
const columns = useMemo(() =>
getRfqDetailColumns({
setRowAction,
- unreadMessages
- }), [unreadMessages])
+ unreadMessages,
+ onQuotationClick: handleOpenHistoryDialog,
+ openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet])
// 필터 필드 정의 (메모이제이션)
const advancedFilterFields = useMemo(
@@ -493,6 +574,22 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
)}
</div>
<div className="flex gap-2">
+ {/* 벤더 선택 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleAcceptVendors}
+ disabled={selectedRows.length === 0 || isAcceptingVendors}
+ className="gap-2"
+ >
+ {isAcceptingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <CheckCircle className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 선택</span>
+ </Button>
+
{/* RFQ 발송 버튼 */}
<Button
variant="outline"
@@ -525,22 +622,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
<span>벤더 삭제</span>
</Button>
- {/* 견적 비교 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenComparisonDialog}
- className="gap-2"
- disabled={
- !selectedRfq ||
- details.length === 0 ||
- vendorsWithQuotations === 0
- }
- >
- <BarChart2 className="size-4" aria-hidden="true" />
- <span>견적 비교/선택</span>
- </Button>
-
{/* 벤더 추가 버튼 */}
<Button
variant="outline"
@@ -586,7 +667,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
<AddVendorDialog
open={vendorDialogOpen}
onOpenChange={setVendorDialogOpen}
- selectedRfq={selectedRfq}
+ selectedRfq={selectedRfq as unknown as TechSalesRfq}
existingVendorIds={existingVendorIds}
onSuccess={handleRefreshData}
/>
@@ -600,13 +681,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
onSuccess={handleRefreshData}
/>
- {/* 견적 비교 다이얼로그 */}
- <VendorQuotationComparisonDialog
- open={comparisonDialogOpen}
- onOpenChange={setComparisonDialogOpen}
- selectedRfq={selectedRfq}
- />
-
{/* 다중 벤더 삭제 확인 다이얼로그 */}
<DeleteVendorsDialog
open={deleteConfirmDialogOpen}
@@ -615,6 +689,22 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
onConfirm={executeDeleteVendors}
isLoading={isDeletingVendors}
/>
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={selectedQuotationId}
+ />
+
+ {/* 견적서 첨부파일 Sheet */}
+ <TechSalesQuotationAttachmentsSheet
+ open={quotationAttachmentsSheetOpen}
+ onOpenChange={setQuotationAttachmentsSheetOpen}
+ quotation={selectedQuotationInfo}
+ attachments={quotationAttachments}
+ isLoading={isLoadingAttachments}
+ />
</div>
)
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
deleted file mode 100644
index 0a6caa5c..00000000
--- a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
+++ /dev/null
@@ -1,341 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useEffect, useState } from "react"
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import { Skeleton } from "@/components/ui/skeleton"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
-import { toast } from "sonner"
-import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
-
-// Lucide 아이콘
-import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react"
-
-import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service"
-import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions"
-import { formatCurrency, formatDate } from "@/lib/utils"
-import { techSalesVendorQuotations } from "@/db/schema/techSales"
-
-// 기술영업 견적 정보 타입
-interface TechSalesVendorQuotation {
- id: number
- rfqId: number
- vendorId: number
- vendorName?: string | null
- totalPrice: string | null
- currency: string | null
- validUntil: Date | null
- status: string
- remark: string | null
- submittedAt: Date | null
- acceptedAt: Date | null
- createdAt: Date
- updatedAt: Date
-}
-
-interface VendorQuotationComparisonDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedRfq: {
- id: number;
- rfqCode: string | null;
- status: string;
- [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
- } | null
-}
-
-export function VendorQuotationComparisonDialog({
- open,
- onOpenChange,
- selectedRfq,
-}: VendorQuotationComparisonDialogProps) {
- const [isLoading, setIsLoading] = useState(false)
- const [quotations, setQuotations] = useState<TechSalesVendorQuotation[]>([])
- const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null)
- const [isAccepting, setIsAccepting] = useState(false)
- const [showConfirmDialog, setShowConfirmDialog] = useState(false)
-
- useEffect(() => {
- async function loadQuotationData() {
- if (!open || !selectedRfq?.id) return
-
- try {
- setIsLoading(true)
- // 기술영업 견적 목록 조회 (제출된 견적만)
- const result = await getTechSalesVendorQuotationsWithJoin({
- rfqId: selectedRfq.id,
- page: 1,
- perPage: 100,
- filters: [
- {
- id: "status" as keyof typeof techSalesVendorQuotations,
- value: "Submitted",
- type: "select" as const,
- operator: "eq" as const,
- rowId: "status"
- }
- ]
- })
-
- setQuotations(result.data || [])
- } catch (error) {
- console.error("견적 데이터 로드 오류:", error)
- toast.error("견적 데이터를 불러오는 데 실패했습니다")
- } finally {
- setIsLoading(false)
- }
- }
-
- loadQuotationData()
- }, [open, selectedRfq])
-
- // 견적 상태 -> 뱃지 색
- const getStatusBadgeVariant = (status: string) => {
- switch (status) {
- case "Submitted":
- return "default"
- case "Accepted":
- return "default"
- case "Rejected":
- return "destructive"
- case "Revised":
- return "destructive"
- default:
- return "secondary"
- }
- }
-
- // 벤더 선택 핸들러
- const handleSelectVendor = (vendorId: number) => {
- setSelectedVendorId(vendorId)
- setShowConfirmDialog(true)
- }
-
- // 벤더 선택 확정
- const handleConfirmSelection = async () => {
- if (!selectedVendorId) return
-
- try {
- setIsAccepting(true)
-
- // 선택된 견적의 ID 찾기
- const selectedQuotation = quotations.find(q => q.vendorId === selectedVendorId)
- if (!selectedQuotation) {
- toast.error("선택된 견적을 찾을 수 없습니다")
- return
- }
-
- // 벤더 선택 API 호출
- const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id)
-
- if (result.success) {
- toast.success(result.message || "벤더가 선택되었습니다")
- setShowConfirmDialog(false)
- onOpenChange(false)
-
- // 페이지 새로고침 또는 데이터 재로드
- window.location.reload()
- } else {
- toast.error(result.error || "벤더 선택에 실패했습니다")
- }
- } catch (error) {
- console.error("벤더 선택 오류:", error)
- toast.error("벤더 선택에 실패했습니다")
- } finally {
- setIsAccepting(false)
- }
- }
-
- const selectedVendor = quotations.find(q => q.vendorId === selectedVendorId)
-
- return (
- <>
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]">
- <DialogHeader>
- <DialogTitle>벤더 견적 비교 및 선택</DialogTitle>
- <DialogDescription>
- {selectedRfq
- ? `RFQ ${selectedRfq.rfqCode} - 제출된 견적을 비교하고 벤더를 선택하세요`
- : ""}
- </DialogDescription>
- </DialogHeader>
-
- {isLoading ? (
- <div className="space-y-4">
- <Skeleton className="h-8 w-1/2" />
- <Skeleton className="h-48 w-full" />
- </div>
- ) : quotations.length === 0 ? (
- <div className="py-8 text-center text-muted-foreground">
- 제출된(Submitted) 견적이 없습니다
- </div>
- ) : (
- <div className="border rounded-md max-h-[60vh] overflow-auto">
- <table className="table-fixed w-full border-collapse">
- <thead className="sticky top-0 bg-background z-10">
- <TableRow>
- <TableHead className="sticky left-0 top-0 z-20 bg-background p-2 w-32">
- 항목
- </TableHead>
- {quotations.map((q) => (
- <TableHead key={q.id} className="p-2 text-center whitespace-nowrap w-48">
- <div className="flex flex-col items-center gap-2">
- <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
- <Button
- size="sm"
- variant={q.status === "Accepted" ? "default" : "outline"}
- onClick={() => handleSelectVendor(q.vendorId)}
- disabled={q.status === "Accepted"}
- className="gap-1"
- >
- {q.status === "Accepted" ? (
- <>
- <CheckCircle className="h-4 w-4" />
- 선택됨
- </>
- ) : (
- "선택"
- )}
- </Button>
- </div>
- </TableHead>
- ))}
- </TableRow>
- </thead>
- <tbody>
- {/* 견적 상태 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 견적 상태
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`status-${q.id}`} className="p-2 text-center">
- <Badge variant={getStatusBadgeVariant(q.status)}>
- {q.status}
- </Badge>
- </TableCell>
- ))}
- </TableRow>
-
- {/* 총 금액 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 총 금액
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`total-${q.id}`} className="p-2 font-semibold text-center">
- {q.totalPrice ? formatCurrency(Number(q.totalPrice), q.currency || 'USD') : '-'}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 통화 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 통화
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`currency-${q.id}`} className="p-2 text-center">
- {q.currency || '-'}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 유효기간 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 유효 기간
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`valid-${q.id}`} className="p-2 text-center">
- {q.validUntil ? formatDate(q.validUntil, "KR") : '-'}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 제출일 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 제출일
- </TableCell>
- {quotations.map((q) => (
- <TableCell key={`submitted-${q.id}`} className="p-2 text-center">
- {q.submittedAt ? formatDate(q.submittedAt, "KR") : '-'}
- </TableCell>
- ))}
- </TableRow>
-
- {/* 비고 */}
- <TableRow>
- <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
- 비고
- </TableCell>
- {quotations.map((q) => (
- <TableCell
- key={`remark-${q.id}`}
- className="p-2 whitespace-pre-wrap text-center"
- >
- {q.remark || "-"}
- </TableCell>
- ))}
- </TableRow>
- </tbody>
- </table>
- </div>
- )}
-
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 닫기
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-
- {/* 벤더 선택 확인 다이얼로그 */}
- <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>벤더 선택 확인</AlertDialogTitle>
- <AlertDialogDescription>
- <strong>{selectedVendor?.vendorName || `벤더 ID: ${selectedVendorId}`}</strong>를 선택하시겠습니까?
- <br />
- <br />
- 선택된 벤더의 견적이 승인되며, 다른 벤더들의 견적은 자동으로 거절됩니다.
- 이 작업은 되돌릴 수 없습니다.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isAccepting}>취소</AlertDialogCancel>
- <AlertDialogAction
- onClick={handleConfirmSelection}
- disabled={isAccepting}
- className="gap-2"
- >
- {isAccepting && <Loader2 className="h-4 w-4 animate-spin" />}
- 확인
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
-}
diff --git a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
index 10bc9f1f..289ad312 100644
--- a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
+++ b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
@@ -30,10 +30,10 @@ interface RfqItemsViewDialogProps {
onOpenChange: (open: boolean) => void;
rfq: {
id: number;
- rfqCode?: string;
+ rfqCode?: string | null;
status?: string;
description?: string;
- rfqType?: "SHIP" | "TOP" | "HULL";
+ rfqType?: "SHIP" | "TOP" | "HULL" | null;
} | null;
}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index 51c143a4..3009e036 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -6,13 +6,14 @@ import { formatDate, formatDateTime } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { DataTableRowAction } from "@/types/table"
-import { Paperclip, Package } from "lucide-react"
+import { Paperclip, Package, FileText, BarChart3 } from "lucide-react"
import { Button } from "@/components/ui/button"
// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함)
type TechSalesRfq = {
id: number
rfqCode: string | null
+ description: string | null
dueDate: Date
rfqSendDate: Date | null
status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
@@ -33,6 +34,8 @@ type TechSalesRfq = {
projMsrm: number
ptypeNm: string
attachmentCount: number
+ hasTbeAttachments: boolean
+ hasCbeAttachments: boolean
quotationCount: number
itemCount: number
// 나머지 필드는 사용할 때마다 추가
@@ -41,7 +44,7 @@ type TechSalesRfq = {
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
- openAttachmentsSheet: (rfqId: number) => void;
+ openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void;
openItemsDialog: (rfq: TechSalesRfq) => void;
}
@@ -110,6 +113,18 @@ export function getColumns({
size: 120,
},
{
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("description")}</div>,
+ meta: {
+ excelHeader: "RFQ Title"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
accessorKey: "projNm",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트명" />
@@ -286,14 +301,14 @@ export function getColumns({
{
id: "attachments",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ <DataTableColumnHeaderSimple column={column} title="RFQ 첨부파일" />
),
cell: ({ row }) => {
const rfq = row.original
const attachmentCount = rfq.attachmentCount || 0
const handleClick = () => {
- openAttachmentsSheet(rfq.id)
+ openAttachmentsSheet(rfq.id, 'RFQ_COMMON')
}
return (
@@ -325,5 +340,81 @@ export function getColumns({
excelHeader: "첨부파일"
},
},
+ {
+ id: "tbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasTbeAttachments = rfq.hasTbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'TBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ >
+ <FileText className="h-4 w-4 text-muted-foreground group-hover:text-green-600 transition-colors" />
+ {hasTbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "TBE 결과"
+ },
+ },
+ {
+ id: "cbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="CBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasCbeAttachments = rfq.hasCbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'CBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ >
+ <BarChart3 className="h-4 w-4 text-muted-foreground group-hover:text-blue-600 transition-colors" />
+ {hasCbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "CBE 결과"
+ },
+ },
]
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index 424ca70e..615753cd 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -57,6 +57,7 @@ interface TechSalesRfq {
ptypeNm: string
attachmentCount: number
quotationCount: number
+ rfqType: "SHIP" | "TOP" | "HULL" | null
// 필요에 따라 다른 필드들 추가
[key: string]: unknown
}
@@ -135,7 +136,7 @@ export function RFQListTable({
to: searchParams?.get('to') || undefined,
columnVisibility: {},
columnOrder: [],
- pinnedColumns: { left: [], right: ["items", "attachments"] },
+ pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
groupBy: [],
expandedRows: []
}), [searchParams])
@@ -170,6 +171,7 @@ export function RFQListTable({
setSelectedRfq({
id: rfqData.id,
rfqCode: rfqData.rfqCode,
+ rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
biddingProjectId: rfqData.biddingProjectId,
materialCode: rfqData.materialCode,
dueDate: rfqData.dueDate,
@@ -201,6 +203,7 @@ export function RFQListTable({
setProjectDetailRfq({
id: projectRfqData.id,
rfqCode: projectRfqData.rfqCode,
+ rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
biddingProjectId: projectRfqData.biddingProjectId,
materialCode: projectRfqData.materialCode,
dueDate: projectRfqData.dueDate,
@@ -238,8 +241,11 @@ export function RFQListTable({
}
}, [rowAction])
+ // 첨부파일 시트 상태에 타입 추가
+ const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
+
// 첨부파일 시트 열기 함수
- const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
try {
// 선택된 RFQ 찾기
const rfq = tableData?.data?.find(r => r.id === rfqId)
@@ -248,6 +254,9 @@ export function RFQListTable({
return
}
+ // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
+ const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+
// 실제 첨부파일 목록 조회 API 호출
const result = await getTechSalesRfqAttachments(rfqId)
@@ -256,8 +265,11 @@ export function RFQListTable({
return
}
+ // 해당 타입의 첨부파일만 필터링
+ const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
+
// API 응답을 ExistingTechSalesAttachment 형식으로 변환
- const attachments: ExistingTechSalesAttachment[] = result.data.map(att => ({
+ const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
id: att.id,
techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
fileName: att.fileName,
@@ -265,12 +277,13 @@ export function RFQListTable({
filePath: att.filePath,
fileSize: att.fileSize || undefined,
fileType: att.fileType || undefined,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC",
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
description: att.description || undefined,
createdBy: att.createdBy,
createdAt: att.createdAt,
}))
+ setAttachmentType(validAttachmentType)
setAttachmentsDefault(attachments)
setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
setAttachmentsOpen(true)
@@ -561,6 +574,7 @@ export function RFQListTable({
onOpenChange={setAttachmentsOpen}
defaultAttachments={attachmentsDefault}
rfq={selectedRfqForAttachments}
+ attachmentType={attachmentType}
onAttachmentsUpdated={handleAttachmentsUpdated}
/>
diff --git a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
new file mode 100644
index 00000000..21c61773
--- /dev/null
+++ b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Download, FileText, File, ImageIcon, AlertCircle } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { formatDate } from "@/lib/utils"
+import prettyBytes from "pretty-bytes"
+
+// 견적서 첨부파일 타입 정의
+export interface QuotationAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ uploadedBy: number
+ vendorId: number
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+// 견적서 정보 타입
+interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface TechSalesQuotationAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ quotation: QuotationInfo | null
+ attachments: QuotationAttachment[]
+ isLoading?: boolean
+}
+
+export function TechSalesQuotationAttachmentsSheet({
+ quotation,
+ attachments,
+ isLoading = false,
+ ...props
+}: TechSalesQuotationAttachmentsSheetProps) {
+
+ // 파일 아이콘 선택 함수
+ const getFileIcon = (fileName: string) => {
+ const ext = fileName.split('.').pop()?.toLowerCase();
+ if (!ext) return <File className="h-5 w-5 text-gray-500" />;
+
+ // 이미지 파일
+ if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(ext)) {
+ return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ }
+ // PDF 파일
+ if (ext === 'pdf') {
+ return <FileText className="h-5 w-5 text-red-500" />;
+ }
+ // Excel 파일
+ if (['xlsx', 'xls', 'csv'].includes(ext)) {
+ return <FileText className="h-5 w-5 text-green-500" />;
+ }
+ // Word 파일
+ if (['docx', 'doc'].includes(ext)) {
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ }
+ // 기본 파일
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 파일 다운로드 처리
+ const handleDownload = (attachment: QuotationAttachment) => {
+ const link = document.createElement('a');
+ link.href = attachment.filePath;
+ link.download = attachment.originalFileName || attachment.fileName;
+ link.target = '_blank';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ // 리비전별로 첨부파일 그룹핑
+ const groupedAttachments = React.useMemo(() => {
+ const groups = new Map<number, QuotationAttachment[]>();
+
+ attachments.forEach(attachment => {
+ const revisionId = attachment.revisionId;
+ if (!groups.has(revisionId)) {
+ groups.set(revisionId, []);
+ }
+ groups.get(revisionId)!.push(attachment);
+ });
+
+ // 리비전 ID 기준 내림차순 정렬 (최신 버전이 위에)
+ return Array.from(groups.entries())
+ .sort(([a], [b]) => b - a)
+ .map(([revisionId, files]) => ({
+ revisionId,
+ files: files.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ }));
+ }, [attachments]);
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>견적서 첨부파일</SheetTitle>
+ <SheetDescription>
+ <div className="space-y-1">
+ <div>견적서: {quotation?.quotationCode || "N/A"}</div>
+ {quotation?.vendorName && (
+ <div>벤더: {quotation.vendorName}</div>
+ )}
+ {quotation?.rfqCode && (
+ <div>RFQ: {quotation.rfqCode}</div>
+ )}
+ </div>
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-auto">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
+ <p className="text-sm text-muted-foreground">첨부파일 로딩 중...</p>
+ </div>
+ </div>
+ ) : attachments.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-8 text-center">
+ <AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
+ <p className="text-muted-foreground mb-2">첨부파일이 없습니다</p>
+ <p className="text-sm text-muted-foreground">
+ 이 견적서에는 첨부된 파일이 없습니다.
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h6 className="font-semibold text-sm">
+ 첨부파일 ({attachments.length}개)
+ </h6>
+ </div>
+
+ {groupedAttachments.map((group, groupIndex) => (
+ <div key={group.revisionId} className="space-y-3">
+ {/* 리비전 헤더 */}
+ <div className="flex items-center gap-2">
+ <Badge variant={group.revisionId === 0 ? "secondary" : "outline"} className="text-xs">
+ {group.revisionId === 0 ? "초기 버전" : `버전 ${group.revisionId}`}
+ </Badge>
+ <span className="text-xs text-muted-foreground">
+ ({group.files.length}개 파일)
+ </span>
+ </div>
+
+ {/* 해당 리비전의 첨부파일들 */}
+ {group.files.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-start gap-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors ml-4"
+ >
+ <div className="mt-1">
+ {getFileIcon(attachment.fileName)}
+ </div>
+
+ <div className="flex-1 min-w-0">
+ <div className="flex items-start justify-between gap-2">
+ <div className="min-w-0 flex-1">
+ <p className="text-sm font-medium break-words leading-tight">
+ {attachment.originalFileName || attachment.fileName}
+ </p>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ {prettyBytes(attachment.fileSize)}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {attachment.isVendorUpload ? "벤더 업로드" : "시스템"}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ {formatDate(attachment.createdAt)}
+ </p>
+ {attachment.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ {/* 다운로드 버튼 */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleDownload(attachment)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+
+ {/* 그룹 간 구분선 (마지막 그룹 제외) */}
+ {groupIndex < groupedAttachments.length - 1 && (
+ <Separator className="my-4" />
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
index ecdf6d81..a7b487e1 100644
--- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
+++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
@@ -27,7 +27,6 @@ import {
import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Dropzone,
@@ -63,7 +62,7 @@ export interface ExistingTechSalesAttachment {
filePath: string
fileSize?: number
fileType?: string
- attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"
+ attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
description?: string
createdBy: number
createdAt: Date
@@ -72,7 +71,7 @@ export interface ExistingTechSalesAttachment {
/** 새로 업로드할 파일 */
const newUploadSchema = z.object({
fileObj: z.any().optional(), // 실제 File
- attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]).default("RFQ_COMMON"),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
description: z.string().optional(),
})
@@ -85,7 +84,7 @@ const existingAttachSchema = z.object({
filePath: z.string(),
fileSize: z.number().optional(),
fileType: z.string().optional(),
- attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
description: z.string().optional(),
createdBy: z.number(),
createdAt: z.custom<Date>(),
@@ -112,27 +111,54 @@ interface TechSalesRfqAttachmentsSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
defaultAttachments?: ExistingTechSalesAttachment[]
rfq: TechSalesRfq | null
+ /** 첨부파일 타입 */
+ attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
/** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
- onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
- /** 강제 읽기 전용 모드 (파트너/벤더용) */
- readOnly?: boolean
+ // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
+
}
export function TechSalesRfqAttachmentsSheet({
defaultAttachments = [],
- onAttachmentsUpdated,
+ // onAttachmentsUpdated,
rfq,
- readOnly = false,
+ attachmentType = "RFQ_COMMON",
...props
}: TechSalesRfqAttachmentsSheetProps) {
const [isPending, setIsPending] = React.useState(false)
- // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
- const isEditable = React.useMemo(() => {
- if (!rfq || readOnly) return false
- // RFQ Created, RFQ Vendor Assignned 상태에서만 편집 가능
- return ["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)
- }, [rfq, readOnly])
+ // 첨부파일 타입별 제목과 설명 설정
+ const attachmentConfig = React.useMemo(() => {
+ switch (attachmentType) {
+ case "TBE_RESULT":
+ return {
+ title: "TBE 결과 첨부파일",
+ description: "기술 평가(TBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "TBE 결과",
+ canEdit: true
+ }
+ case "CBE_RESULT":
+ return {
+ title: "CBE 결과 첨부파일",
+ description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "CBE 결과",
+ canEdit: true
+ }
+ default: // RFQ_COMMON
+ return {
+ title: "RFQ 첨부파일",
+ description: "RFQ 공통 첨부파일을 관리합니다.",
+ fileTypeLabel: "공통",
+ canEdit: true
+ }
+ }
+ }, [attachmentType, rfq?.status])
+
+ // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
+ // const isEditable = React.useMemo(() => {
+ // if (!rfq) return false
+ // return attachmentConfig.canEdit
+ // }, [rfq, attachmentConfig.canEdit])
const form = useForm<AttachmentsFormValues>({
resolver: zodResolver(attachmentsFormSchema),
@@ -236,7 +262,7 @@ export function TechSalesRfqAttachmentsSheet({
.filter(upload => upload.fileObj)
.map(upload => ({
file: upload.fileObj as File,
- attachmentType: upload.attachmentType,
+ attachmentType: attachmentType,
description: upload.description,
}))
@@ -268,50 +294,50 @@ export function TechSalesRfqAttachmentsSheet({
toast.success(successMessage)
- // 즉시 첨부파일 목록 새로고침
- const refreshResult = await getTechSalesRfqAttachments(rfq.id)
- if (refreshResult.error) {
- console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
- toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
- } else {
- // 새로운 첨부파일 목록으로 폼 업데이트
- const refreshedAttachments = refreshResult.data.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId || rfq.id,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize,
- fileType: att.fileType,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC",
- description: att.description,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- }))
-
- // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
- form.reset({
- techSalesRfqId: rfq.id,
- existing: refreshedAttachments.map(att => ({
- ...att,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- description: att.description || undefined,
- })),
- newUploads: [],
- })
+ // // 즉시 첨부파일 목록 새로고침
+ // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
+ // if (refreshResult.error) {
+ // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
+ // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
+ // } else {
+ // // 새로운 첨부파일 목록으로 폼 업데이트
+ // const refreshedAttachments = refreshResult.data.map(att => ({
+ // id: att.id,
+ // techSalesRfqId: att.techSalesRfqId || rfq.id,
+ // fileName: att.fileName,
+ // originalFileName: att.originalFileName,
+ // filePath: att.filePath,
+ // fileSize: att.fileSize,
+ // fileType: att.fileType,
+ // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ // description: att.description,
+ // createdBy: att.createdBy,
+ // createdAt: att.createdAt,
+ // }))
+
+ // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
+ // form.reset({
+ // techSalesRfqId: rfq.id,
+ // existing: refreshedAttachments.map(att => ({
+ // ...att,
+ // fileSize: att.fileSize || undefined,
+ // fileType: att.fileType || undefined,
+ // description: att.description || undefined,
+ // })),
+ // newUploads: [],
+ // })
- // 즉시 UI 업데이트를 위한 추가 피드백
- if (uploadedCount > 0) {
- toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
- }
- }
+ // // 즉시 UI 업데이트를 위한 추가 피드백
+ // if (uploadedCount > 0) {
+ // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
+ // }
+ // }
- // 콜백으로 상위 컴포넌트에 변경사항 알림
- const newAttachmentCount = refreshResult.error ?
- (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
- refreshResult.data.length
- onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
+ // // 콜백으로 상위 컴포넌트에 변경사항 알림
+ // const newAttachmentCount = refreshResult.error ?
+ // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
+ // refreshResult.data.length
+ // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
} catch (error) {
console.error("첨부파일 저장 오류:", error)
@@ -325,10 +351,11 @@ export function TechSalesRfqAttachmentsSheet({
<Sheet {...props}>
<SheetContent className="flex flex-col gap-6 sm:max-w-md">
<SheetHeader className="text-left">
- <SheetTitle>첨부파일 관리</SheetTitle>
+ <SheetTitle>{attachmentConfig.title}</SheetTitle>
<SheetDescription>
- RFQ: {rfq?.rfqCode || "N/A"}
- {!isEditable && (
+ <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
+ <div className="mt-1">{attachmentConfig.description}</div>
+ {!attachmentConfig.canEdit && (
<div className="mt-2 flex items-center gap-2 text-amber-600">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
@@ -345,7 +372,7 @@ export function TechSalesRfqAttachmentsSheet({
기존 첨부파일 ({existingFields.length}개)
</h6>
{existingFields.map((field, index) => {
- const typeLabel = field.attachmentType === "RFQ_COMMON" ? "공통" : "벤더별"
+ const typeLabel = attachmentConfig.fileTypeLabel
const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
const dateText = field.createdAt ? formatDate(field.createdAt) : ""
@@ -384,7 +411,7 @@ export function TechSalesRfqAttachmentsSheet({
</a>
)}
{/* Remove button - 편집 가능할 때만 표시 */}
- {isEditable && (
+ {attachmentConfig.canEdit && (
<Button
type="button"
variant="ghost"
@@ -402,7 +429,7 @@ export function TechSalesRfqAttachmentsSheet({
</div>
{/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
- {isEditable ? (
+ {attachmentConfig.canEdit ? (
<>
<Dropzone
maxSize={MAX_FILE_SIZE}
@@ -467,30 +494,6 @@ export function TechSalesRfqAttachmentsSheet({
</FileListAction>
</FileListHeader>
- {/* 파일별 설정 */}
- <div className="px-4 pb-3 space-y-3">
- <FormField
- control={form.control}
- name={`newUploads.${idx}.attachmentType`}
- render={({ field: formField }) => (
- <FormItem>
- <FormLabel className="text-xs">파일 타입</FormLabel>
- <Select onValueChange={formField.onChange} defaultValue={formField.value}>
- <FormControl>
- <SelectTrigger className="h-8">
- <SelectValue placeholder="파일 타입 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="RFQ_COMMON">공통 파일</SelectItem>
- {/* <SelectItem value="VENDOR_SPECIFIC">벤더별 파일</SelectItem> */}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
</FileListItem>
)
})}
@@ -510,10 +513,10 @@ export function TechSalesRfqAttachmentsSheet({
<SheetFooter className="gap-2 pt-2 sm:space-x-0">
<SheetClose asChild>
<Button type="button" variant="outline">
- {isEditable ? "취소" : "닫기"}
+ {attachmentConfig.canEdit ? "취소" : "닫기"}
</Button>
</SheetClose>
- {isEditable && (
+ {attachmentConfig.canEdit && (
<Button
type="submit"
disabled={
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
index 3449dcb6..20b2703c 100644
--- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
-import { useState } from "react"
+import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
-import { CalendarIcon, Save, Send, AlertCircle } from "lucide-react"
+import { CalendarIcon, Send, AlertCircle, Upload, X, FileText, Download } from "lucide-react"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -26,6 +26,13 @@ interface QuotationResponseTabProps {
currency: string | null
validUntil: Date | null
remark: string | null
+ quotationAttachments?: Array<{
+ id: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
rfq: {
id: number
rfqCode: string | null
@@ -58,38 +65,93 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
)
const [remark, setRemark] = useState(quotation.remark || "")
const [isLoading, setIsLoading] = useState(false)
+ const [attachments, setAttachments] = useState<Array<{
+ id?: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ isNew?: boolean
+ file?: File
+ }>>([])
+ const [isUploadingFiles, setIsUploadingFiles] = useState(false)
const router = useRouter()
+ // // 초기 첨부파일 데이터 로드
+ // useEffect(() => {
+ // if (quotation.quotationAttachments) {
+ // setAttachments(quotation.quotationAttachments.map(att => ({
+ // id: att.id,
+ // fileName: att.fileName,
+ // fileSize: att.fileSize,
+ // filePath: att.filePath,
+ // isNew: false
+ // })))
+ // }
+ // }, [quotation.quotationAttachments])
+
const rfq = quotation.rfq
const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
- const canSubmit = quotation.status === "Draft" && !isDueDatePassed
- const canEdit = ["Draft", "Revised"].includes(quotation.status) && !isDueDatePassed
+ const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+ const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+
+ // 파일 업로드 핸들러
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (!files) return
+
+ Array.from(files).forEach(file => {
+ setAttachments(prev => [
+ ...prev,
+ {
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '',
+ isNew: true,
+ file
+ }
+ ])
+ })
+ }
+
+ // 첨부파일 제거
+ const removeAttachment = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 파일 업로드 함수
+ const uploadFiles = async () => {
+ const newFiles = attachments.filter(att => att.isNew && att.file)
+ if (newFiles.length === 0) return []
+
+ setIsUploadingFiles(true)
+ const uploadedFiles = []
- const handleSaveDraft = async () => {
- setIsLoading(true)
try {
- const { updateTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
-
- const result = await updateTechSalesVendorQuotation({
- id: quotation.id,
- currency,
- totalPrice,
- validUntil: validUntil!,
- remark,
- updatedBy: 1 // TODO: 실제 사용자 ID로 변경
- })
+ for (const attachment of newFiles) {
+ const formData = new FormData()
+ formData.append('file', attachment.file!)
+
+ const response = await fetch('/api/upload', {
+ method: 'POST',
+ body: formData
+ })
- if (result.error) {
- toast.error(result.error)
- } else {
- toast.success("임시 저장되었습니다.")
- // 페이지 새로고침 대신 router.refresh() 사용
- router.refresh()
+ if (!response.ok) throw new Error('파일 업로드 실패')
+
+ const result = await response.json()
+ uploadedFiles.push({
+ fileName: result.fileName,
+ filePath: result.url,
+ fileSize: attachment.fileSize
+ })
}
- } catch {
- toast.error("저장 중 오류가 발생했습니다.")
+ return uploadedFiles
+ } catch (error) {
+ console.error('파일 업로드 오류:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ return []
} finally {
- setIsLoading(false)
+ setIsUploadingFiles(false)
}
}
@@ -101,6 +163,9 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
setIsLoading(true)
try {
+ // 파일 업로드 먼저 처리
+ const uploadedFiles = await uploadFiles()
+
const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
const result = await submitTechSalesVendorQuotation({
@@ -109,6 +174,7 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
totalPrice,
validUntil: validUntil!,
remark,
+ attachments: uploadedFiles,
updatedBy: 1 // TODO: 실제 사용자 ID로 변경
})
@@ -116,8 +182,10 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
toast.error(result.error)
} else {
toast.success("견적서가 제출되었습니다.")
- // 페이지 새로고침 대신 router.refresh() 사용
- router.refresh()
+ // // 페이지 새로고침 대신 router.refresh() 사용
+ // router.refresh()
+ // 페이지 새로고침
+ window.location.reload()
}
} catch {
toast.error("제출 중 오류가 발생했습니다.")
@@ -312,28 +380,98 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
/>
</div>
+ {/* 첨부파일 */}
+ <div className="space-y-4">
+ <Label>첨부파일</Label>
+
+ {/* 파일 업로드 버튼 */}
+ {canEdit && (
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ disabled={isUploadingFiles}
+ onClick={() => document.getElementById('file-input')?.click()}
+ >
+ <Upload className="h-4 w-4 mr-2" />
+ 파일 선택
+ </Button>
+ <input
+ id="file-input"
+ type="file"
+ multiple
+ onChange={handleFileSelect}
+ className="hidden"
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.zip"
+ />
+ <span className="text-sm text-muted-foreground">
+ PDF, 문서파일, 이미지파일, 압축파일 등
+ </span>
+ </div>
+ )}
+
+ {/* 첨부파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="space-y-2">
+ {attachments.map((attachment, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 border rounded-lg bg-muted/50"
+ >
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <div className="text-sm font-medium">{attachment.fileName}</div>
+ <div className="text-xs text-muted-foreground">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ {attachment.isNew && (
+ <Badge variant="secondary" className="ml-2">
+ 새 파일
+ </Badge>
+ )}
+ </div>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {!attachment.isNew && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => window.open(attachment.filePath, '_blank')}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {canEdit && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachment(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
{/* 액션 버튼 */}
- {canEdit && (
- <div className="flex gap-2 pt-4">
+ {canEdit && canSubmit && (
+ <div className="flex justify-center pt-4">
<Button
- variant="outline"
- onClick={handleSaveDraft}
- disabled={isLoading}
- className="flex-1"
+ onClick={handleSubmit}
+ disabled={isLoading || !totalPrice || !currency || !validUntil}
+ className="w-full "
>
- <Save className="mr-2 h-4 w-4" />
- 임시 저장
+ <Send className="mr-2 h-4 w-4" />
+ 견적서 제출
</Button>
- {canSubmit && (
- <Button
- onClick={handleSubmit}
- disabled={isLoading || !totalPrice || !currency || !validUntil}
- className="flex-1"
- >
- <Send className="mr-2 h-4 w-4" />
- 견적서 제출
- </Button>
- )}
</div>
)}
</CardContent>
diff --git a/lib/techsales-rfq/vendor-response/quotation-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-editor.tsx
deleted file mode 100644
index 54058214..00000000
--- a/lib/techsales-rfq/vendor-response/quotation-editor.tsx
+++ /dev/null
@@ -1,559 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect } from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import * as z from "zod"
-import { toast } from "sonner"
-import { useRouter } from "next/navigation"
-import { CalendarIcon, Save, Send, ArrowLeft } from "lucide-react"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
-import { DatePicker } from "@/components/ui/date-picker"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { Skeleton } from "@/components/ui/skeleton"
-
-import { formatCurrency, formatDate } from "@/lib/utils"
-import {
- updateTechSalesVendorQuotation,
- submitTechSalesVendorQuotation,
- fetchCurrencies
-} from "../service"
-
-// 견적서 폼 스키마 (techsales용 단순화)
-const quotationFormSchema = z.object({
- currency: z.string().min(1, "통화를 선택해주세요"),
- totalPrice: z.string().min(1, "총액을 입력해주세요"),
- validUntil: z.date({
- required_error: "견적 유효기간을 선택해주세요",
- invalid_type_error: "유효한 날짜를 선택해주세요",
- }),
- remark: z.string().optional(),
-})
-
-type QuotationFormValues = z.infer<typeof quotationFormSchema>
-
-// 통화 타입
-interface Currency {
- code: string
- name: string
-}
-
-// 이 컴포넌트에 전달되는 견적서 데이터 타입 (techsales용 단순화)
-interface TechSalesVendorQuotation {
- id: number
- rfqId: number
- vendorId: number
- quotationCode: string | null
- quotationVersion: number | null
- totalPrice: string | null
- currency: string | null
- validUntil: Date | null
- status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted"
- remark: string | null
- rejectionReason: string | null
- submittedAt: Date | null
- acceptedAt: Date | null
- createdAt: Date
- updatedAt: Date
- rfq: {
- id: number
- rfqCode: string | null
- dueDate: Date | null
- status: string | null
- materialCode: string | null
- remark: string | null
- projectSnapshot?: {
- pspid?: string
- projNm?: string
- sector?: string
- projMsrm?: number
- kunnr?: string
- kunnrNm?: string
- ptypeNm?: string
- } | null
- seriesSnapshot?: Array<{
- pspid: string
- sersNo: string
- scDt?: string
- klDt?: string
- lcDt?: string
- dlDt?: string
- dockNo?: string
- dockNm?: string
- projNo?: string
- post1?: string
- }> | null
- item?: {
- id: number
- itemCode: string | null
- itemList: string | null
- } | null
- biddingProject?: {
- id: number
- pspid: string | null
- projNm: string | null
- } | null
- createdByUser?: {
- id: number
- name: string | null
- email: string | null
- } | null
- }
- vendor: {
- id: number
- vendorName: string
- vendorCode: string | null
- }
-}
-
-interface TechSalesQuotationEditorProps {
- quotation: TechSalesVendorQuotation
-}
-
-export default function TechSalesQuotationEditor({ quotation }: TechSalesQuotationEditorProps) {
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [isSaving, setIsSaving] = useState(false)
- const [currencies, setCurrencies] = useState<Currency[]>([])
- const [loadingCurrencies, setLoadingCurrencies] = useState(true)
-
- // 폼 초기화
- const form = useForm<QuotationFormValues>({
- resolver: zodResolver(quotationFormSchema),
- defaultValues: {
- currency: quotation.currency || "USD",
- totalPrice: quotation.totalPrice || "",
- validUntil: quotation.validUntil || undefined,
- remark: quotation.remark || "",
- },
- })
-
- // 통화 목록 로드
- useEffect(() => {
- const loadCurrencies = async () => {
- try {
- const { data, error } = await fetchCurrencies()
- if (error) {
- toast.error("통화 목록을 불러오는데 실패했습니다")
- return
- }
- setCurrencies(data || [])
- } catch (error) {
- console.error("Error loading currencies:", error)
- toast.error("통화 목록을 불러오는데 실패했습니다")
- } finally {
- setLoadingCurrencies(false)
- }
- }
-
- loadCurrencies()
- }, [])
-
- // 마감일 확인
- const isBeforeDueDate = () => {
- if (!quotation.rfq.dueDate) return true
- return new Date() <= new Date(quotation.rfq.dueDate)
- }
-
- // 편집 가능 여부 확인
- const isEditable = () => {
- return quotation.status === "Draft" || quotation.status === "Rejected"
- }
-
- // 제출 가능 여부 확인
- const isSubmitReady = () => {
- const values = form.getValues()
- return values.currency &&
- values.totalPrice &&
- parseFloat(values.totalPrice) > 0 &&
- values.validUntil &&
- isBeforeDueDate()
- }
-
- // 저장 핸들러
- const handleSave = async () => {
- if (!isEditable()) {
- toast.error("편집할 수 없는 상태입니다")
- return
- }
-
- setIsSaving(true)
- try {
- const values = form.getValues()
- const { data, error } = await updateTechSalesVendorQuotation({
- id: quotation.id,
- currency: values.currency,
- totalPrice: values.totalPrice,
- validUntil: values.validUntil,
- remark: values.remark,
- updatedBy: quotation.vendorId, // 임시로 vendorId 사용
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- toast.success("견적서가 저장되었습니다")
- router.refresh()
- } catch (error) {
- console.error("Error saving quotation:", error)
- toast.error("견적서 저장 중 오류가 발생했습니다")
- } finally {
- setIsSaving(false)
- }
- }
-
- // 제출 핸들러
- const handleSubmit = async () => {
- if (!isEditable()) {
- toast.error("제출할 수 없는 상태입니다")
- return
- }
-
- if (!isSubmitReady()) {
- toast.error("필수 항목을 모두 입력해주세요")
- return
- }
-
- if (!isBeforeDueDate()) {
- toast.error("마감일이 지났습니다")
- return
- }
-
- setIsSubmitting(true)
- try {
- const values = form.getValues()
- const { data, error } = await submitTechSalesVendorQuotation({
- id: quotation.id,
- currency: values.currency,
- totalPrice: values.totalPrice,
- validUntil: values.validUntil,
- remark: values.remark,
- updatedBy: quotation.vendorId, // 임시로 vendorId 사용
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- toast.success("견적서가 제출되었습니다")
- router.push("/ko/partners/techsales/rfq-ship")
- } catch (error) {
- console.error("Error submitting quotation:", error)
- toast.error("견적서 제출 중 오류가 발생했습니다")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 상태 배지
- const getStatusBadge = (status: string) => {
- const statusConfig = {
- "Draft": { label: "초안", variant: "secondary" as const },
- "Submitted": { label: "제출됨", variant: "default" as const },
- "Revised": { label: "수정됨", variant: "outline" as const },
- "Rejected": { label: "반려됨", variant: "destructive" as const },
- "Accepted": { label: "승인됨", variant: "success" as const },
- }
-
- const config = statusConfig[status as keyof typeof statusConfig] || {
- label: status,
- variant: "secondary" as const
- }
-
- return <Badge variant={config.variant}>{config.label}</Badge>
- }
-
- return (
- <div className="container max-w-4xl mx-auto py-6 space-y-6">
- {/* 헤더 */}
- <div className="flex items-center justify-between">
- <div className="flex items-center space-x-4">
- <Button
- variant="ghost"
- size="sm"
- onClick={() => router.back()}
- >
- <ArrowLeft className="h-4 w-4 mr-2" />
- 뒤로가기
- </Button>
- <div>
- <h1 className="text-2xl font-bold">기술영업 견적서</h1>
- <p className="text-muted-foreground">
- RFQ: {quotation.rfq.rfqCode} | {getStatusBadge(quotation.status)}
- </p>
- </div>
- </div>
- <div className="flex items-center space-x-2">
- {isEditable() && (
- <>
- <Button
- variant="outline"
- onClick={handleSave}
- disabled={isSaving}
- >
- <Save className="h-4 w-4 mr-2" />
- {isSaving ? "저장 중..." : "저장"}
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isSubmitting || !isSubmitReady()}
- >
- <Send className="h-4 w-4 mr-2" />
- {isSubmitting ? "제출 중..." : "제출"}
- </Button>
- </>
- )}
- </div>
- </div>
-
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
- {/* 왼쪽: RFQ 정보 */}
- <div className="lg:col-span-1 space-y-6">
- {/* RFQ 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle>RFQ 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div>
- <label className="text-sm font-medium text-muted-foreground">RFQ 번호</label>
- <p className="font-mono">{quotation.rfq.rfqCode}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">자재 그룹</label>
- <p>{quotation.rfq.materialCode || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">자재명</label>
- <p>{quotation.rfq.item?.itemList || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">마감일</label>
- <p className={!isBeforeDueDate() ? "text-red-600 font-medium" : ""}>
- {quotation.rfq.dueDate ? formatDate(quotation.rfq.dueDate) : "N/A"}
- </p>
- </div>
- {quotation.rfq.remark && (
- <div>
- <label className="text-sm font-medium text-muted-foreground">비고</label>
- <p className="text-sm">{quotation.rfq.remark}</p>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 프로젝트 정보 */}
- {quotation.rfq.projectSnapshot && (
- <Card>
- <CardHeader>
- <CardTitle>프로젝트 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-3">
- <div>
- <label className="text-sm font-medium text-muted-foreground">프로젝트 번호</label>
- <p className="font-mono">{quotation.rfq.projectSnapshot.pspid}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">프로젝트명</label>
- <p>{quotation.rfq.projectSnapshot.projNm || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">선종</label>
- <p>{quotation.rfq.projectSnapshot.ptypeNm || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">척수</label>
- <p>{quotation.rfq.projectSnapshot.projMsrm || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">선주</label>
- <p>{quotation.rfq.projectSnapshot.kunnrNm || "N/A"}</p>
- </div>
- </CardContent>
- </Card>
- )}
-
- {/* 시리즈 정보 */}
- {quotation.rfq.seriesSnapshot && quotation.rfq.seriesSnapshot.length > 0 && (
- <Card>
- <CardHeader>
- <CardTitle>시리즈 일정</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="space-y-3">
- {quotation.rfq.seriesSnapshot.map((series, index) => (
- <div key={index} className="border rounded p-3">
- <div className="font-medium mb-2">시리즈 {series.sersNo}</div>
- <div className="grid grid-cols-2 gap-2 text-sm">
- {series.klDt && (
- <div>
- <span className="text-muted-foreground">K/L:</span> {formatDate(series.klDt)}
- </div>
- )}
- {series.dlDt && (
- <div>
- <span className="text-muted-foreground">인도:</span> {formatDate(series.dlDt)}
- </div>
- )}
- </div>
- </div>
- ))}
- </div>
- </CardContent>
- </Card>
- )}
- </div>
-
- {/* 오른쪽: 견적서 입력 폼 */}
- <div className="lg:col-span-2">
- <Card>
- <CardHeader>
- <CardTitle>견적서 작성</CardTitle>
- <CardDescription>
- 총액 기반으로 견적을 작성해주세요.
- </CardDescription>
- </CardHeader>
- <CardContent>
- <Form {...form}>
- <form className="space-y-6">
- {/* 통화 선택 */}
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <FormLabel>통화 *</FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- disabled={!isEditable()}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="통화를 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {loadingCurrencies ? (
- <div className="p-2">
- <Skeleton className="h-4 w-full" />
- </div>
- ) : (
- currencies.map((currency) => (
- <SelectItem key={currency.code} value={currency.code}>
- {currency.code} - {currency.name}
- </SelectItem>
- ))
- )}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 총액 입력 */}
- <FormField
- control={form.control}
- name="totalPrice"
- render={({ field }) => (
- <FormItem>
- <FormLabel>총액 *</FormLabel>
- <FormControl>
- <Input
- type="number"
- step="0.01"
- placeholder="총액을 입력하세요"
- disabled={!isEditable()}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 유효기간 */}
- <FormField
- control={form.control}
- name="validUntil"
- render={({ field }) => (
- <FormItem>
- <FormLabel>견적 유효기간 *</FormLabel>
- <FormControl>
- <DatePicker
- date={field.value}
- onDateChange={field.onChange}
- disabled={!isEditable()}
- placeholder="유효기간을 선택하세요"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 비고 */}
- <FormField
- control={form.control}
- name="remark"
- render={({ field }) => (
- <FormItem>
- <FormLabel>비고</FormLabel>
- <FormControl>
- <Textarea
- placeholder="추가 설명이나 특이사항을 입력하세요"
- disabled={!isEditable()}
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 반려 사유 (반려된 경우에만 표시) */}
- {quotation.status === "Rejected" && quotation.rejectionReason && (
- <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
- <label className="text-sm font-medium text-red-800">반려 사유</label>
- <p className="text-sm text-red-700 mt-1">{quotation.rejectionReason}</p>
- </div>
- )}
-
- {/* 제출 정보 */}
- {quotation.submittedAt && (
- <div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
- <label className="text-sm font-medium text-blue-800">제출 정보</label>
- <p className="text-sm text-blue-700 mt-1">
- 제출일: {formatDate(quotation.submittedAt)}
- </p>
- </div>
- )}
- </form>
- </Form>
- </CardContent>
- </Card>
- </div>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx
deleted file mode 100644
index 92bec96a..00000000
--- a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx
+++ /dev/null
@@ -1,664 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { toast } from "sonner"
-import { format } from "date-fns"
-
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Checkbox } from "@/components/ui/checkbox"
-import { DatePicker } from "@/components/ui/date-picker"
-import {
- Table,
- TableBody,
- TableCaption,
- TableCell,
- TableHead,
- TableHeader,
- TableRow
-} from "@/components/ui/table"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger
-} from "@/components/ui/tooltip"
-import {
- Info,
- Clock,
- CalendarIcon,
- ClipboardCheck,
- AlertTriangle,
- CheckCircle2,
- RefreshCw,
- Save,
- FileText,
- Sparkles
-} from "lucide-react"
-
-import { formatCurrency } from "@/lib/utils"
-import { updateQuotationItem } from "../services"
-import { Textarea } from "@/components/ui/textarea"
-
-// 견적 아이템 타입
-interface QuotationItem {
- id: number
- quotationId: number
- prItemId: number
- materialCode: string | null
- materialDescription: string | null
- quantity: number
- uom: string | null
- unitPrice: number
- totalPrice: number
- currency: string
- vendorMaterialCode: string | null
- vendorMaterialDescription: string | null
- deliveryDate: Date | null
- leadTimeInDays: number | null
- taxRate: number | null
- taxAmount: number | null
- discountRate: number | null
- discountAmount: number | null
- remark: string | null
- isAlternative: boolean
- isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음
- createdAt: Date
- updatedAt: Date
- prItem?: {
- id: number
- materialCode: string | null
- materialDescription: string | null
- // 기타 필요한 정보
- }
-}
-
-// debounce 함수 구현
-function debounce<T extends (...args: any[]) => any>(
- func: T,
- wait: number
-): (...args: Parameters<T>) => void {
- let timeout: NodeJS.Timeout | null = null;
-
- return function (...args: Parameters<T>) {
- if (timeout) clearTimeout(timeout);
- timeout = setTimeout(() => func(...args), wait);
- };
-}
-
-interface QuotationItemEditorProps {
- items: QuotationItem[]
- onItemsChange: (items: QuotationItem[]) => void
- disabled?: boolean
- currency: string
-}
-
-export function QuotationItemEditor({
- items,
- onItemsChange,
- disabled = false,
- currency
-}: QuotationItemEditorProps) {
- const [editingItem, setEditingItem] = useState<number | null>(null)
- const [isSaving, setIsSaving] = useState(false)
-
- // 저장이 필요한 항목들을 추적
- const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set())
-
- // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음
- const updateLocalItem = <K extends keyof QuotationItem>(
- index: number,
- field: K,
- value: QuotationItem[K]
- ) => {
- // 로컬 상태 업데이트
- const updatedItems = [...items]
- const item = { ...updatedItems[index] }
-
- // 필드 업데이트
- item[field] = value
-
- // 대체품 체크 해제 시 관련 필드 초기화
- if (field === 'isAlternative' && value === false) {
- item.vendorMaterialCode = null;
- item.vendorMaterialDescription = null;
- item.remark = null;
- }
-
- // 단가나 수량이 변경되면 총액 계산
- if (field === 'unitPrice' || field === 'quantity') {
- item.totalPrice = Number(item.unitPrice) * Number(item.quantity)
-
- // 세금이 있으면 세액 계산
- if (item.taxRate) {
- item.taxAmount = item.totalPrice * (item.taxRate / 100)
- }
-
- // 할인이 있으면 할인액 계산
- if (item.discountRate) {
- item.discountAmount = item.totalPrice * (item.discountRate / 100)
- }
- }
-
- // 세율이 변경되면 세액 계산
- if (field === 'taxRate') {
- item.taxAmount = item.totalPrice * (value as number / 100)
- }
-
- // 할인율이 변경되면 할인액 계산
- if (field === 'discountRate') {
- item.discountAmount = item.totalPrice * (value as number / 100)
- }
-
- // 변경된 아이템으로 교체
- updatedItems[index] = item
-
- // 미저장 항목으로 표시
- setPendingChanges(prev => new Set(prev).add(item.id))
-
- // 부모 컴포넌트에 변경 사항 알림
- onItemsChange(updatedItems)
-
- // 저장 필요함을 표시
- return item
- }
-
- // 서버에 저장하는 함수
- const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => {
- if (disabled) return
-
- try {
- setIsSaving(true)
-
- const result = await updateQuotationItem({
- id: item.id,
- [field]: value,
- totalPrice: item.totalPrice,
- taxAmount: item.taxAmount ?? 0,
- discountAmount: item.discountAmount ?? 0
- })
-
- // 저장 완료 후 pendingChanges에서 제거
- setPendingChanges(prev => {
- const newSet = new Set(prev)
- newSet.delete(item.id)
- return newSet
- })
-
- if (!result.success) {
- toast.error(result.message || "항목 저장 중 오류가 발생했습니다")
- }
- } catch (error) {
- console.error("항목 저장 오류:", error)
- toast.error("항목 저장 중 오류가 발생했습니다")
- } finally {
- setIsSaving(false)
- }
- }
-
- // debounce된 저장 함수
- const debouncedSave = useRef(debounce(
- (item: QuotationItem, field: keyof QuotationItem, value: any) => {
- saveItemToServer(item, field, value)
- },
- 800 // 800ms 지연
- )).current
-
- // 견적 항목 업데이트 함수
- const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => {
- const updatedItem = updateLocalItem(index, field, value)
-
- // debounce를 통해 서버 저장 지연
- if (!disabled) {
- debouncedSave(updatedItem, field, value)
- }
- }
-
- // 모든 변경 사항 저장
- const saveAllChanges = async () => {
- if (disabled || pendingChanges.size === 0) return
-
- setIsSaving(true)
- toast.info(`${pendingChanges.size}개 항목 저장 중...`)
-
- try {
- // 변경된 모든 항목 저장
- for (const itemId of pendingChanges) {
- const index = items.findIndex(item => item.id === itemId)
- if (index !== -1) {
- const item = items[index]
- await updateQuotationItem({
- id: item.id,
- unitPrice: item.unitPrice,
- totalPrice: item.totalPrice,
- taxRate: item.taxRate ?? 0,
- taxAmount: item.taxAmount ?? 0,
- discountRate: item.discountRate ?? 0,
- discountAmount: item.discountAmount ?? 0,
- deliveryDate: item.deliveryDate,
- leadTimeInDays: item.leadTimeInDays ?? 0,
- vendorMaterialCode: item.vendorMaterialCode ?? "",
- vendorMaterialDescription: item.vendorMaterialDescription ?? "",
- isAlternative: item.isAlternative,
- isRecommended: false, // 항상 false로 설정 (사용하지 않음)
- remark: item.remark ?? ""
- })
- }
- }
-
- // 모든 변경 사항 저장 완료
- setPendingChanges(new Set())
- toast.success("모든 변경 사항이 저장되었습니다")
- } catch (error) {
- console.error("변경 사항 저장 오류:", error)
- toast.error("변경 사항 저장 중 오류가 발생했습니다")
- } finally {
- setIsSaving(false)
- }
- }
-
- // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후)
- const handleBlur = (index: number, field: keyof QuotationItem, value: any) => {
- const itemId = items[index].id
-
- // 해당 항목이 pendingChanges에 있다면 즉시 저장
- if (pendingChanges.has(itemId)) {
- const item = items[index]
- saveItemToServer(item, field, value)
- }
- }
-
- // 전체 단가 업데이트 (일괄 반영)
- const handleBulkUnitPriceUpdate = () => {
- if (items.length === 0) return
-
- // 첫 번째 아이템의 단가 가져오기
- const firstUnitPrice = items[0].unitPrice
-
- if (!firstUnitPrice) {
- toast.error("첫 번째 항목의 단가를 먼저 입력해주세요")
- return
- }
-
- // 모든 아이템에 동일한 단가 적용
- const updatedItems = items.map(item => ({
- ...item,
- unitPrice: firstUnitPrice,
- totalPrice: firstUnitPrice * item.quantity,
- taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount,
- discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount
- }))
-
- // 모든 아이템을 변경 필요 항목으로 표시
- setPendingChanges(new Set(updatedItems.map(item => item.id)))
-
- // 부모 컴포넌트에 변경 사항 알림
- onItemsChange(updatedItems)
-
- toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.")
- }
-
- // 입력 핸들러
- const handleNumberInputChange = (
- index: number,
- field: keyof QuotationItem,
- e: React.ChangeEvent<HTMLInputElement>
- ) => {
- const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
- handleItemUpdate(index, field, value)
- }
-
- const handleTextInputChange = (
- index: number,
- field: keyof QuotationItem,
- e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
- ) => {
- handleItemUpdate(index, field, e.target.value)
- }
-
- const handleDateChange = (
- index: number,
- field: keyof QuotationItem,
- date: Date | undefined
- ) => {
- handleItemUpdate(index, field, date || null)
- }
-
- const handleCheckboxChange = (
- index: number,
- field: keyof QuotationItem,
- checked: boolean
- ) => {
- handleItemUpdate(index, field, checked)
- }
-
- // 날짜 형식 지정
- const formatDeliveryDate = (date: Date | null) => {
- if (!date) return "-"
- return format(date, "yyyy-MM-dd")
- }
-
- // 입력 폼 필드 렌더링
- const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => {
- if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') {
- return (
- <Input
- type="number"
- min={0}
- step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1}
- value={item[field] as number || 0}
- onChange={(e) => handleNumberInputChange(index, field, e)}
- onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)}
- disabled={disabled || isSaving}
- className="w-full"
- />
- )
- } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') {
- return (
- <Input
- type="text"
- value={item[field] as string || ''}
- onChange={(e) => handleTextInputChange(index, field, e)}
- onBlur={(e) => handleBlur(index, field, e.target.value)}
- disabled={disabled || isSaving || !item.isAlternative}
- className="w-full"
- placeholder={field === 'vendorMaterialCode' ? "벤더 자재그룹" : "벤더 자재명"}
- />
- )
- } else if (field === 'deliveryDate') {
- return (
- <DatePicker
- date={item.deliveryDate ? new Date(item.deliveryDate) : undefined}
- onSelect={(date) => {
- handleDateChange(index, field, date);
- // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거
- if (date) handleBlur(index, field, date);
- }}
- disabled={disabled || isSaving}
- />
- )
- } else if (field === 'isAlternative') {
- return (
- <div className="flex items-center gap-1">
- <Checkbox
- checked={item.isAlternative}
- onCheckedChange={(checked) => {
- handleCheckboxChange(index, field, checked as boolean);
- handleBlur(index, field, checked as boolean);
- }}
- disabled={disabled || isSaving}
- />
- <span className="text-xs">대체품</span>
- </div>
- )
- }
-
- return null
- }
-
- // 대체품 필드 렌더링
- const renderAlternativeFields = (item: QuotationItem, index: number) => {
- if (!item.isAlternative) return null;
-
- return (
- <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm">
- {/* <div className="flex flex-col gap-2">
- <label className="text-xs font-medium text-blue-700">벤더 자재그룹</label>
- <Input
- value={item.vendorMaterialCode || ""}
- onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)}
- onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)}
- disabled={disabled || isSaving}
- className="h-8 text-sm"
- placeholder="벤더 자재그룹 입력"
- />
- </div> */}
-
- <div className="flex flex-col gap-2">
- <label className="text-xs font-medium text-blue-700">벤더 자재명</label>
- <Input
- value={item.vendorMaterialDescription || ""}
- onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)}
- onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)}
- disabled={disabled || isSaving}
- className="h-8 text-sm"
- placeholder="벤더 자재명 입력"
- />
- </div>
-
- <div className="flex flex-col gap-2">
- <label className="text-xs font-medium text-blue-700">대체품 설명</label>
- <Textarea
- value={item.remark || ""}
- onChange={(e) => handleTextInputChange(index, 'remark', e)}
- onBlur={(e) => handleBlur(index, 'remark', e.target.value)}
- disabled={disabled || isSaving}
- className="min-h-[60px] text-sm"
- placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요"
- />
- </div>
- </div>
- );
- };
-
- // 항목의 저장 상태 아이콘 표시
- const renderSaveStatus = (itemId: number) => {
- if (pendingChanges.has(itemId)) {
- return (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" />
- </TooltipTrigger>
- <TooltipContent>
- <p>저장되지 않은 변경 사항이 있습니다</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- )
- }
-
- return null
- }
-
- return (
- <div className="space-y-4">
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2">
- <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3>
- {pendingChanges.size > 0 && (
- <Badge variant="outline" className="bg-yellow-50">
- 변경 {pendingChanges.size}개
- </Badge>
- )}
- </div>
-
- <div className="flex items-center gap-2">
- {pendingChanges.size > 0 && !disabled && (
- <Button
- variant="default"
- size="sm"
- onClick={saveAllChanges}
- disabled={isSaving}
- >
- {isSaving ? (
- <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <Save className="h-4 w-4 mr-2" />
- )}
- 변경사항 저장 ({pendingChanges.size}개)
- </Button>
- )}
-
- {!disabled && (
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkUnitPriceUpdate}
- disabled={items.length === 0 || isSaving}
- >
- 첫 항목 단가로 일괄 적용
- </Button>
- )}
- </div>
- </div>
-
- <ScrollArea className="h-[500px] rounded-md border">
- <Table>
- <TableHeader className="sticky top-0 bg-background">
- <TableRow>
- <TableHead className="w-[50px]">번호</TableHead>
- <TableHead>자재그룹</TableHead>
- <TableHead>자재명</TableHead>
- <TableHead>수량</TableHead>
- <TableHead>단위</TableHead>
- <TableHead>단가</TableHead>
- <TableHead>금액</TableHead>
- <TableHead>
- <div className="flex items-center gap-1">
- 세율(%)
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Info className="h-4 w-4" />
- </TooltipTrigger>
- <TooltipContent>
- <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </TableHead>
- <TableHead>
- <div className="flex items-center gap-1">
- 납품일
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Info className="h-4 w-4" />
- </TooltipTrigger>
- <TooltipContent>
- <p>납품 가능한 날짜를 선택해주세요.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </TableHead>
- <TableHead>리드타임(일)</TableHead>
- <TableHead>
- <div className="flex items-center gap-1">
- 대체품
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Info className="h-4 w-4" />
- </TooltipTrigger>
- <TooltipContent>
- <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p>
- <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </TableHead>
- <TableHead className="w-[50px]">상태</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {items.length === 0 ? (
- <TableRow>
- <TableCell colSpan={12} className="text-center py-10">
- 견적 항목이 없습니다
- </TableCell>
- </TableRow>
- ) : (
- items.map((item, index) => (
- <React.Fragment key={item.id}>
- <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}>
- <TableCell>
- {index + 1}
- </TableCell>
- <TableCell>
- {item.materialCode || "-"}
- </TableCell>
- <TableCell>
- <div className="font-medium max-w-xs truncate">
- {item.materialDescription || "-"}
- </div>
- </TableCell>
- <TableCell>
- {item.quantity}
- </TableCell>
- <TableCell>
- {item.uom || "-"}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'unitPrice')}
- </TableCell>
- <TableCell>
- {formatCurrency(item.totalPrice, currency)}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'taxRate')}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'deliveryDate')}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'leadTimeInDays')}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'isAlternative')}
- </TableCell>
- <TableCell>
- {renderSaveStatus(item.id)}
- </TableCell>
- </TableRow>
-
- {/* 대체품으로 선택된 경우 추가 정보 행 표시 */}
- {item.isAlternative && (
- <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}>
- <TableCell colSpan={1}></TableCell>
- <TableCell colSpan={10}>
- {renderAlternativeFields(item, index)}
- </TableCell>
- <TableCell colSpan={1}></TableCell>
- </TableRow>
- )}
- </React.Fragment>
- ))
- )}
- </TableBody>
- </Table>
- </ScrollArea>
-
- {isSaving && (
- <div className="flex items-center justify-center text-sm text-muted-foreground">
- <Clock className="h-4 w-4 animate-spin mr-2" />
- 변경 사항을 저장 중입니다...
- </div>
- )}
-
- <div className="bg-muted p-4 rounded-md">
- <h4 className="text-sm font-medium mb-2">안내 사항</h4>
- <ul className="text-sm space-y-1 text-muted-foreground">
- <li className="flex items-start gap-2">
- <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
- <span>단가와 납품일은 필수로 입력해야 합니다.</span>
- </li>
- <li className="flex items-start gap-2">
- <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" />
- <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span>
- </li>
- <li className="flex items-start gap-2">
- <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" />
- <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span>
- </li>
- </ul>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index b89f8953..39de94ed 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -30,7 +30,6 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
// 아이템 정보
itemName?: string;
-
itemCount?: number;
// 프로젝트 정보
@@ -38,6 +37,9 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
pspid?: string;
sector?: string;
+ // RFQ 정보
+ description?: string;
+
// 벤더 정보
vendorName?: string;
vendorCode?: string;
@@ -194,6 +196,33 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge
// enableHiding: true,
// },
{
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ title" />
+ ),
+ cell: ({ row }) => {
+ const description = row.getValue("description") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {description || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{description || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
accessorKey: "projNm",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트명" />
@@ -313,7 +342,6 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge
cell: ({ row }) => {
const quotation = row.original
const attachmentCount = quotation.attachmentCount || 0
-
const handleClick = () => {
openAttachmentsSheet(quotation.rfqId)
}
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
index 5e5d4f39..4c5cdf8e 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
@@ -38,12 +38,15 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
rfqStatus?: string;
itemName?: string | null;
projNm?: string | null;
- quotationCode?: string | null;
-
- rejectionReason?: string | null;
- acceptedAt?: Date | null;
+ description?: string | null;
attachmentCount?: number;
itemCount?: number;
+ pspid?: string | null;
+ sector?: string | null;
+ vendorName?: string | null;
+ vendorCode?: string | null;
+ createdByName?: string | null;
+ updatedByName?: string | null;
}
interface VendorQuotationsTableProps {
@@ -380,7 +383,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab
// useDataTable 훅 사용
const { table } = useDataTable({
data: stableData,
- columns,
+ columns: columns as any,
pageCount,
rowCount: total,
filterFields,
@@ -391,7 +394,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab
enableRowSelection: true, // 행 선택 활성화
initialState: {
sorting: initialSettings.sort,
- columnPinning: { right: ["actions"] },
+ columnPinning: { right: ["actions", "items", "attachments"] },
},
getRowId: (originalRow) => String(originalRow.id),
shallow: false,
@@ -417,13 +420,6 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab
<div className="w-full">
<div className="overflow-x-auto">
<div className="relative">
- {/* 로딩 오버레이 (재로딩 시) */}
- {/* {!isInitialLoad && isLoading && (
- <div className="absolute h-full w-full inset-0 bg-background/90 backdrop-blur-md z-10 flex items-center justify-center">
- <CenterLoadingIndicator />
- </div>
- )} */}
-
<DataTable
table={table}
className="min-w-full"