summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/procurement-rfqs/repository.ts50
-rw-r--r--lib/procurement-rfqs/services.ts2055
-rw-r--r--lib/procurement-rfqs/table/pr-item-dialog.tsx258
-rw-r--r--lib/procurement-rfqs/table/rfq-table-column.tsx373
-rw-r--r--lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx279
-rw-r--r--lib/procurement-rfqs/table/rfq-table.tsx209
-rw-r--r--lib/procurement-rfqs/validations.ts61
-rw-r--r--lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx522
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-editor.tsx953
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx664
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx239
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx145
12 files changed, 5808 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/repository.ts b/lib/procurement-rfqs/repository.ts
new file mode 100644
index 00000000..eb48bc42
--- /dev/null
+++ b/lib/procurement-rfqs/repository.ts
@@ -0,0 +1,50 @@
+// src/lib/tasks/repository.ts
+import db from "@/db/db";
+import { procurementRfqsView } from "@/db/schema";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt, sql
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+/**
+ * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시
+ * - 트랜잭션(tx)을 받아서 사용하도록 구현
+ */
+export async function selectPORfqs(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(procurementRfqsView)
+ .where(where ?? undefined)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+/** 총 개수 count */
+export async function countPORfqs(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(procurementRfqsView).where(where);
+ return res[0]?.count ?? 0;
+}
+
diff --git a/lib/procurement-rfqs/services.ts b/lib/procurement-rfqs/services.ts
new file mode 100644
index 00000000..7179b213
--- /dev/null
+++ b/lib/procurement-rfqs/services.ts
@@ -0,0 +1,2055 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+import { GetPORfqsSchema, GetQuotationsSchema } from "./validations";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count, between } from "drizzle-orm";
+import { incoterms, paymentTerms, prItems, prItemsView, procurementAttachments, procurementQuotationItems, procurementRfqComments, procurementRfqDetails, procurementRfqDetailsView, procurementRfqs, procurementRfqsView, procurementVendorQuotations } from "@/db/schema/procurementRFQ";
+import { countPORfqs, selectPORfqs } from "./repository";
+import { writeFile, mkdir } from "fs/promises"
+import { join } from "path"
+import { v4 as uuidv4 } from "uuid"
+import { items, projects, users, vendors } from "@/db/schema";
+import { formatISO } from "date-fns";
+import { sendEmail } from "../mail/sendEmail";
+import { formatDate } from "../utils";
+
+async function getAuthenticatedUser() {
+ const session = await getServerSession(authOptions);
+
+ if (!session || !session.user?.id) {
+ throw new Error("인증이 필요합니다");
+ }
+
+ return {
+ userId: session.user.id,
+ user: session.user
+ };
+}
+
+
+export async function getPORfqs(input: GetPORfqsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 기본 필터 처리 - RFQFilterBox에서 오는 필터
+ const basicFilters = input.basicFilters || [];
+ const basicJoinOperator = input.basicJoinOperator || "and";
+
+ // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터
+ const advancedFilters = input.filters || [];
+ const advancedJoinOperator = input.joinOperator || "and";
+
+ // 기본 필터 조건 생성
+ let basicWhere;
+ if (basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: procurementRfqsView,
+ filters: basicFilters,
+ joinOperator: basicJoinOperator,
+ });
+ }
+
+ // 고급 필터 조건 생성
+ let advancedWhere;
+ if (advancedFilters.length > 0) {
+ advancedWhere = filterColumns({
+ table: procurementRfqsView,
+ filters: advancedFilters,
+ joinOperator: advancedJoinOperator,
+ });
+ }
+
+ // 전역 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(procurementRfqsView.rfqCode, s),
+ ilike(procurementRfqsView.projectCode, s),
+ ilike(procurementRfqsView.projectName, s),
+ ilike(procurementRfqsView.dueDate, s),
+ ilike(procurementRfqsView.status, s),
+ // 발주담당 검색 추가
+ ilike(procurementRfqsView.picCode, s)
+ );
+ }
+
+ // 날짜 범위 필터링을 위한 특별 처리 (RFQFilterBox는 이미 basicFilters에 포함)
+ // 이 코드는 기존 처리와의 호환성을 위해 유지
+ let dateRangeWhere;
+ if (input.filters) {
+ const rfqSendDateFilter = input.filters.find(f => f.id === "rfqSendDate" && Array.isArray(f.value));
+
+ if (rfqSendDateFilter && Array.isArray(rfqSendDateFilter.value)) {
+ const [fromDate, toDate] = rfqSendDateFilter.value;
+
+ if (fromDate && toDate) {
+ // 시작일과 종료일이 모두 있는 경우
+ dateRangeWhere = between(
+ procurementRfqsView.rfqSendDate,
+ new Date(fromDate),
+ new Date(toDate)
+ );
+ } else if (fromDate) {
+ // 시작일만 있는 경우
+ dateRangeWhere = sql`${procurementRfqsView.rfqSendDate} >= ${new Date(fromDate)}`;
+ }
+ }
+ }
+
+ // 모든 조건 결합
+ let whereConditions = [];
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (dateRangeWhere) whereConditions.push(dateRangeWhere);
+
+ // 조건이 있을 때만 and() 사용
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+
+
+ // 정렬 조건 - 안전하게 처리
+ const orderBy =
+ input.sort && input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(procurementRfqsView[item.id])
+ : asc(procurementRfqsView[item.id])
+ )
+ : [desc(procurementRfqsView.updatedAt)]
+
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectPORfqs(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countPORfqs(tx, finalWhere);
+ return { data, total };
+ });
+
+ console.log(total)
+
+ console.log("쿼리 결과 데이터:", data.length);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount ,total };
+ } catch (err) {
+ console.error("getRfqs 에러:", err);
+
+ // 에러 세부 정보 더 자세히 로깅
+ if (err instanceof Error) {
+ console.error("에러 메시지:", err.message);
+ console.error("에러 스택:", err.stack);
+
+ if ('code' in err) {
+ console.error("SQL 에러 코드:", (err as any).code);
+ }
+ }
+
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: [`rfqs-po`],
+ }
+ )();
+}
+
+// RFQ 디테일 데이터를 가져오는 함수
+export async function getRfqDetails(rfqId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ unstable_noStore();
+
+ // SQL 쿼리 직접 실행
+ const data = await db
+ .select()
+ .from(procurementRfqDetailsView)
+ .where(eq(procurementRfqDetailsView.rfqId, rfqId))
+
+ console.log(`RFQ 디테일 SQL 조회 완료: ${rfqId}, ${data?.length}건`);
+
+ return { data };
+ } catch (err) {
+ console.error("RFQ 디테일 SQL 조회 오류:", err);
+
+ if (err instanceof Error) {
+ console.error("에러 메시지:", err.message);
+ console.error("에러 스택:", err.stack);
+ }
+
+ return { data: [] };
+ }
+ },
+ [`rfq-details-sql-${rfqId}`],
+ {
+ revalidate: 60,
+ tags: [`rfq-details-${rfqId}`],
+ }
+ )();
+}
+
+// RFQ ID로 디테일 데이터를 가져오는 서버 액션
+export async function fetchRfqDetails(rfqId: number) {
+ "use server";
+
+ try {
+ const result = await getRfqDetails(rfqId);
+ return result;
+ } catch (error) {
+ console.error("RFQ 디테일 서버 액션 오류:", error);
+ return { data: [] };
+ }
+}
+
+// RFQ ID로 PR 상세 항목들을 가져오는 함수
+export async function getPrItemsByRfqId(rfqId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ unstable_noStore();
+
+
+ const data = await db
+ .select()
+ .from(prItemsView)
+ .where(eq(prItemsView.procurementRfqsId, rfqId))
+
+
+ console.log(`PR 항목 조회 완료: ${rfqId}, ${data.length}건`);
+
+ return { data };
+ } catch (err) {
+ console.error("PR 항목 조회 오류:", err);
+
+ if (err instanceof Error) {
+ console.error("에러 메시지:", err.message);
+ console.error("에러 스택:", err.stack);
+ }
+
+ return { data: [] };
+ }
+ },
+ [`pr-items-${rfqId}`],
+ {
+ revalidate: 60, // 1분 캐시
+ tags: [`pr-items-${rfqId}`],
+ }
+ )();
+}
+
+// 서버 액션으로 노출할 함수
+export async function fetchPrItemsByRfqId(rfqId: number) {
+ "use server";
+
+ try {
+ const result = await getPrItemsByRfqId(rfqId);
+ return result;
+ } catch (error) {
+ console.error("PR 항목 서버 액션 오류:", error);
+ return { data: [] };
+ }
+}
+
+export async function addVendorToRfq(formData: FormData) {
+ try {
+ // 현재 사용자 정보 가져오기
+ const { userId, user } = await getAuthenticatedUser();
+ console.log("userId", userId);
+ // rfqId 가져오기
+ const rfqId = Number(formData.get("rfqId"))
+
+ if (!rfqId) {
+ return {
+ success: false,
+ message: "RFQ ID가 필요합니다",
+ }
+ }
+
+ // 폼 데이터 추출 및 기본 검증 (기존과 동일)
+ const vendorId = Number(formData.get("vendorId"))
+ const currency = formData.get("currency") as string
+ const paymentTermsCode = formData.get("paymentTermsCode") as string
+ const incotermsCode = formData.get("incotermsCode") as string
+ const incotermsDetail = formData.get("incotermsDetail") as string || null
+ const deliveryDate = formData.get("deliveryDate") ? new Date(formData.get("deliveryDate") as string) : null
+ const taxCode = formData.get("taxCode") as string || null
+ const placeOfShipping = formData.get("placeOfShipping") as string || null
+ const placeOfDestination = formData.get("placeOfDestination") as string || null
+ const materialPriceRelatedYn = formData.get("materialPriceRelatedYn") === "true"
+
+ if (!vendorId || !currency || !paymentTermsCode || !incotermsCode) {
+ return {
+ success: false,
+ message: "필수 항목이 누락되었습니다",
+ }
+ }
+
+ // 트랜잭션 시작
+ return await db.transaction(async (tx) => {
+ // 0. 먼저 RFQ 상태 확인
+ const rfq = await tx.query.procurementRfqs.findFirst({
+ where: eq(procurementRfqs.id, rfqId),
+ columns: {
+ id: true,
+ status: true
+ }
+ });
+
+ if (!rfq) {
+ throw new Error("RFQ를 찾을 수 없습니다");
+ }
+ console.log("rfq.status", rfq.status);
+ // 1. RFQ 상세 정보 저장
+ const insertedDetails = await tx.insert(procurementRfqDetails).values({
+ procurementRfqsId: rfqId,
+ vendorsId: vendorId,
+ currency,
+ paymentTermsCode,
+ incotermsCode,
+ incotermsDetail,
+ deliveryDate: deliveryDate || new Date(), // null이면 현재 날짜 사용
+ taxCode,
+ placeOfShipping,
+ placeOfDestination,
+ materialPriceRelatedYn,
+ updatedBy: Number(userId),
+ updatedAt: new Date(),
+ }).returning({ id: procurementRfqDetails.id });
+
+ if (!insertedDetails || insertedDetails.length === 0) {
+ throw new Error("RFQ 상세 정보 저장에 실패했습니다");
+ }
+
+ const detailId = insertedDetails[0].id;
+
+
+
+ // 2. RFQ 상태가 "RFQ Created"인 경우 "RFQ Vendor Assignned"로 업데이트
+ let statusUpdated = false;
+ if (rfq.status === "RFQ Created") {
+ console.log("rfq 상태 업데이트 시작")
+ await tx.update(procurementRfqs)
+ .set({
+ status: "RFQ Vendor Assignned",
+ updatedBy: Number(userId),
+ updatedAt: new Date()
+ })
+ .where(eq(procurementRfqs.id, rfqId));
+
+ statusUpdated = true;
+ }
+
+ // 3. 첨부 파일 처리
+ const filePromises = [];
+ const uploadDir = join(process.cwd(), "public", "rfq", rfqId.toString(), "vendors", detailId.toString());
+
+ // 업로드 디렉토리 생성
+ try {
+ await mkdir(uploadDir, { recursive: true });
+ } catch (error) {
+ console.error("디렉토리 생성 오류:", error);
+ }
+
+ // FormData에서 file 타입 항목 찾기
+ for (const [key, value] of formData.entries()) {
+ if (key.startsWith("attachment-") && value instanceof File) {
+ const file = value as File;
+
+ // 파일 크기가 0이면 건너뛰기
+ if (file.size === 0) continue;
+
+ // 파일 이름 생성
+ const uniqueId = uuidv4();
+ const fileName = `${uniqueId}-${file.name.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
+ const filePath = join(uploadDir, fileName);
+
+ // 파일을 버퍼로 변환
+ const buffer = Buffer.from(await file.arrayBuffer());
+
+ // 파일 저장
+ await writeFile(filePath, buffer);
+
+ // DB에 첨부 파일 정보 저장
+ filePromises.push(
+ tx.insert(procurementAttachments).values({
+ attachmentType: 'VENDOR_SPECIFIC',
+ procurementRfqsId: null,
+ procurementRfqDetailsId: detailId,
+ fileName: fileName,
+ originalFileName: file.name,
+ filePath: `/uploads/rfq/${rfqId}/vendors/${detailId}/${fileName}`,
+ fileSize: file.size,
+ fileType: file.type,
+ description: `${file.name} - 벤더 ID ${vendorId}용 첨부파일`,
+ createdBy: Number(userId),
+ createdAt: new Date(),
+ })
+ );
+ }
+ }
+
+ // 첨부 파일이 있으면 처리
+ if (filePromises.length > 0) {
+ await Promise.all(filePromises);
+ }
+
+ // 캐시 무효화 (여러 경로 지정 가능)
+ revalidateTag(`rfq-details-${rfqId}`);
+ revalidateTag(`rfqs-po`);
+
+ return {
+ success: true,
+ message: "벤더 정보가 성공적으로 추가되었습니다",
+ data: {
+ id: detailId,
+ statusUpdated: statusUpdated
+ },
+ };
+ });
+
+ } catch (error) {
+ console.error("벤더 추가 오류:", error);
+ return {
+ success: false,
+ message: "벤더 추가 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : String(error)),
+ };
+ }
+}
+
+
+// 벤더 데이터 조회 서버 액션
+export async function fetchVendors() {
+ try {
+ const data = await db.select().from(vendors)
+
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error("벤더 데이터 로드 오류:", error)
+ return {
+ success: false,
+ message: "벤더 데이터를 불러오는 데 실패했습니다",
+ data: []
+ }
+ }
+}
+
+// 통화 데이터 조회 서버 액션
+export async function fetchCurrencies() {
+ try {
+ // 통화 테이블이 별도로 없다면 여기서 하드코딩하거나 설정 파일에서 가져올 수도 있습니다
+ const data = [
+ { code: "KRW", name: "Korean Won" },
+ { code: "USD", name: "US Dollar" },
+ { code: "EUR", name: "Euro" },
+ { code: "JPY", name: "Japanese Yen" },
+ { code: "CNY", name: "Chinese Yuan" },
+ ]
+
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error("통화 데이터 로드 오류:", error)
+ return {
+ success: false,
+ message: "통화 데이터를 불러오는 데 실패했습니다",
+ data: []
+ }
+ }
+}
+
+// 지불 조건 데이터 조회 서버 액션
+export async function fetchPaymentTerms() {
+ try {
+ const data = await db.select().from(paymentTerms)
+
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error("지불 조건 데이터 로드 오류:", error)
+ return {
+ success: false,
+ message: "지불 조건 데이터를 불러오는 데 실패했습니다",
+ data: []
+ }
+ }
+}
+
+// 인코텀즈 데이터 조회 서버 액션
+export async function fetchIncoterms() {
+ try {
+ const data = await db.select().from(incoterms)
+
+ return {
+ success: true,
+ data,
+ }
+ } catch (error) {
+ console.error("인코텀즈 데이터 로드 오류:", error)
+ return {
+ success: false,
+ message: "인코텀즈 데이터를 불러오는 데 실패했습니다",
+ data: []
+ }
+ }
+}
+
+export async function deleteRfqDetail(detailId: number) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session || !session.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ };
+ }
+
+ // DB에서 항목 삭제
+ await db.delete(procurementRfqDetails)
+ .where(eq(procurementRfqDetails.id, detailId));
+
+ // 캐시 무효화
+ revalidateTag(`rfq-details-${detailId}`);
+
+ return {
+ success: true,
+ message: "RFQ 벤더 정보가 삭제되었습니다",
+ };
+ } catch (error) {
+ console.error("RFQ 벤더 정보 삭제 오류:", error);
+ return {
+ success: false,
+ message: "RFQ 벤더 정보 삭제 중 오류가 발생했습니다",
+ };
+ }
+}
+
+// RFQ 상세 정보 수정
+export async function updateRfqDetail(detailId: number, data: any) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session || !session.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ };
+ }
+
+ const userId = Number(session.user.id);
+
+ // 필요한 데이터 추출
+ const {
+ vendorId,
+ currency,
+ paymentTermsCode,
+ incotermsCode,
+ incotermsDetail,
+ deliveryDate,
+ taxCode,
+ placeOfShipping,
+ placeOfDestination,
+ materialPriceRelatedYn,
+ } = data;
+
+ // DB 업데이트
+ await db.update(procurementRfqDetails)
+ .set({
+ vendorsId: Number(vendorId),
+ currency,
+ paymentTermsCode,
+ incotermsCode,
+ incotermsDetail: incotermsDetail || null,
+ deliveryDate: deliveryDate ? new Date(deliveryDate) : new Date(),
+ taxCode: taxCode || null,
+ placeOfShipping: placeOfShipping || null,
+ placeOfDestination: placeOfDestination || null,
+ materialPriceRelatedYn,
+ updatedBy: userId,
+ updatedAt: new Date(),
+ })
+ .where(eq(procurementRfqDetails.id, detailId));
+
+ // 캐시 무효화
+ revalidateTag(`rfq-details-${detailId}`);
+
+ return {
+ success: true,
+ message: "RFQ 벤더 정보가 수정되었습니다",
+ };
+ } catch (error) {
+ console.error("RFQ 벤더 정보 수정 오류:", error);
+ return {
+ success: false,
+ message: "RFQ 벤더 정보 수정 중 오류가 발생했습니다",
+ };
+ }
+}
+
+export async function updateRfqRemark(rfqId: number, remark: string) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session || !session.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ };
+ }
+
+ console.log(rfqId, remark)
+
+ // DB 업데이트
+ await db.update(procurementRfqs)
+ .set({
+ remark,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(procurementRfqs.id, rfqId));
+
+ // 캐시 무효화
+ revalidateTag(`rfqs-po`);
+ revalidatePath("/evcp/po-rfq"); // 경로도 함께 무효화
+
+ return {
+ success: true,
+ message: "비고가 업데이트되었습니다",
+ };
+ } catch (error) {
+ console.error("비고 업데이트 오류:", error);
+ return {
+ success: false,
+ message: "비고 업데이트 중 오류가 발생했습니다",
+ };
+ }
+}
+
+export async function sealRfq(rfqId: number) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session || !session.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ };
+ }
+
+ // DB 업데이트
+ await db.update(procurementRfqs)
+ .set({
+ rfqSealedYn: true,
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(procurementRfqs.id, rfqId));
+
+ // 캐시 무효화
+ revalidateTag(`rfqs-po`);
+
+ return {
+ success: true,
+ message: "RFQ가 성공적으로 밀봉되었습니다",
+ };
+ } catch (error) {
+ console.error("RFQ 밀봉 오류:", error);
+ return {
+ success: false,
+ message: "RFQ 밀봉 중 오류가 발생했습니다",
+ };
+ }
+}
+
+// RFQ 전송 서버 액션
+export async function sendRfq(rfqId: number) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ }
+ }
+
+ // 현재 RFQ 상태 확인
+ // RFQ 및 관련 정보 조회
+ const rfq = await db.query.procurementRfqs.findFirst({
+ where: eq(procurementRfqs.id, rfqId),
+ columns: {
+ id: true,
+ rfqCode: true,
+ status: true,
+ dueDate: true,
+ rfqSendDate: true,
+ remark: true,
+ rfqSealedYn: true,
+ },
+ with: {
+ project: {
+ columns: {
+ id: true,
+ code: true,
+ name: true,
+ }
+ },
+ item: {
+ columns: {
+ id: true,
+ itemCode: true,
+ itemName: true,
+ }
+ },
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ },
+ prItems: {
+ columns: {
+ id: true,
+ rfqItem: true, // 아이템 번호
+ materialCode: true,
+ materialDescription: true,
+ quantity: true,
+ uom: true,
+ prNo: true,
+ majorYn: true,
+ }
+ }
+ }
+ });
+
+ if (!rfq) {
+ return {
+ success: false,
+ message: "RFQ를 찾을 수 없습니다",
+ }
+ }
+
+ if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
+ return {
+ success: false,
+ message: "벤더가 할당된 RFQ 또는 이미 전송된 RFQ만 다시 전송할 수 있습니다",
+ }
+ }
+
+ const isResend = rfq.status === "RFQ Sent";
+
+ // 현재 사용자 정보 조회 (CC 용)
+ const sender = await db.query.users.findFirst({
+ where: eq(users.id, Number(session.user.id)),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ }
+ });
+
+ if (!sender || !sender.email) {
+ return {
+ success: false,
+ message: "보내는 사람의 이메일 정보를 찾을 수 없습니다",
+ }
+ }
+
+ // RFQ에 할당된 벤더 목록 조회
+ const rfqDetails = await db.query.procurementRfqDetails.findMany({
+ where: eq(procurementRfqDetails.procurementRfqsId, rfqId),
+ columns: {
+ id: true,
+ vendorsId: true,
+ currency: true,
+ paymentTermsCode: true,
+ incotermsCode: true,
+ incotermsDetail: true,
+ deliveryDate: true,
+ },
+ with: {
+ vendor: {
+ columns: {
+ id: true,
+ vendorName: true,
+ vendorCode: true,
+ }
+ },
+ paymentTerms: {
+ columns: {
+ code: true,
+ description: true,
+ }
+ },
+ incoterms: {
+ columns: {
+ code: true,
+ description: true,
+ }
+ }
+ }
+ });
+
+ if (rfqDetails.length === 0) {
+ return {
+ success: false,
+ message: "할당된 벤더가 없습니다",
+ }
+ }
+
+ // 트랜잭션 시작
+ await db.transaction(async (tx) => {
+ // 1. RFQ 상태 업데이트
+ await tx.update(procurementRfqs)
+ .set({
+ status: "RFQ Sent",
+ rfqSendDate: new Date(),
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(procurementRfqs.id, rfqId));
+
+ // 2. 각 벤더에 대해 초기 견적서 레코드 생성 및 이메일 발송
+ for (const detail of rfqDetails) {
+ if (!detail.vendorsId || !detail.vendor) continue;
+
+ // 기존 Draft 견적서가 있는지 확인
+ const existingQuotation = await tx.query.procurementVendorQuotations.findFirst({
+ where: and(
+ eq(procurementVendorQuotations.rfqId, rfqId),
+ eq(procurementVendorQuotations.vendorId, detail.vendorsId)
+ ),
+ orderBy: [desc(procurementVendorQuotations.quotationVersion)]
+ });
+
+ // 견적서 코드 (기존 것 재사용 또는 신규 생성)
+ const quotationCode = existingQuotation?.quotationCode || `${rfq.rfqCode}-${detail.vendorsId}`;
+
+ // 버전 관리 - 재전송인 경우 버전 증가
+ const quotationVersion = existingQuotation ? ((existingQuotation.quotationVersion? existingQuotation.quotationVersion: 0 )+ 1) : 1;
+
+ // 견적서 레코드 생성
+ const insertedQuotation = await tx.insert(procurementVendorQuotations).values({
+ rfqId,
+ vendorId: detail.vendorsId,
+ quotationCode,
+ quotationVersion,
+ totalItemsCount: rfq.prItems.length,
+ subTotal: "0",
+ taxTotal: "0",
+ discountTotal: "0",
+ totalPrice: "0",
+ currency: detail.currency || "USD",
+ // 납품일은 RFQ 납품일보다 조금 이전으로 설정 (기본값)
+ estimatedDeliveryDate: detail.deliveryDate ?
+ new Date(detail.deliveryDate.getTime() - 7 * 24 * 60 * 60 * 1000) : // 1주일 전
+ undefined,
+ paymentTermsCode: detail.paymentTermsCode,
+ incotermsCode: detail.incotermsCode,
+ incotermsDetail: detail.incotermsDetail,
+ status: "Draft",
+ createdBy: Number(session.user.id),
+ updatedBy: Number(session.user.id),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }).returning({ id: procurementVendorQuotations.id });
+
+ // 새로 생성된 견적서 ID
+ const quotationId = insertedQuotation[0].id;
+
+ // 3. 각 PR 아이템에 대해 견적 아이템 생성
+ for (const prItem of rfq.prItems) {
+ // procurementQuotationItems에 레코드 생성
+ await tx.insert(procurementQuotationItems).values({
+ quotationId,
+ prItemId: prItem.id,
+ materialCode: prItem.materialCode,
+ materialDescription: prItem.materialDescription,
+ quantity: prItem.quantity,
+ uom: prItem.uom,
+ // 기본값으로 설정된 필드
+ unitPrice: 0,
+ totalPrice: 0,
+ currency: detail.currency || "USD",
+ // 나머지 필드는 null 또는 기본값 사용
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ });
+ }
+
+ // 벤더에 속한 모든 사용자 조회
+ const vendorUsers = await db.query.users.findMany({
+ where: eq(users.companyId, detail.vendorsId),
+ columns: {
+ id: true,
+ email: true,
+ name: true,
+ language: true
+ }
+ });
+
+ // 유효한 이메일 주소만 필터링
+ const vendorEmailsString = vendorUsers
+ .filter(user => user.email)
+ .map(user => user.email)
+ .join(", ");
+
+ if (vendorEmailsString) {
+ // 대표 언어 결정 (첫 번째 사용자의 언어 또는 기본값)
+ const language = vendorUsers[0]?.language || "en";
+
+ // 이메일 컨텍스트 구성
+ const emailContext = {
+ language: language,
+ rfq: {
+ id: rfq.id,
+ code: rfq.rfqCode,
+ title: rfq.item?.itemName || '',
+ projectCode: rfq.project?.code || '',
+ projectName: rfq.project?.name || '',
+ description: rfq.remark || '',
+ dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : 'N/A',
+ deliveryDate: detail.deliveryDate ? formatDate(detail.deliveryDate) : 'N/A',
+ },
+ vendor: {
+ id: detail.vendor.id,
+ code: detail.vendor.vendorCode || '',
+ name: detail.vendor.vendorName,
+ },
+ sender: {
+ fullName: sender.name || '',
+ email: sender.email,
+ },
+ items: rfq.prItems.map(item => ({
+ itemNumber: item.rfqItem || '',
+ materialCode: item.materialCode || '',
+ description: item.materialDescription || '',
+ quantity: item.quantity,
+ uom: item.uom || '',
+ })),
+ details: {
+ currency: detail.currency || 'USD',
+ paymentTerms: detail.paymentTerms?.description || detail.paymentTermsCode || 'N/A',
+ incoterms: detail.incoterms ?
+ `${detail.incoterms.code} ${detail.incotermsDetail || ''}` :
+ detail.incotermsCode ? `${detail.incotermsCode} ${detail.incotermsDetail || ''}` : 'N/A',
+ },
+ quotationCode: existingQuotation?.quotationCode || `QUO-${rfqId}-${detail.vendorsId}`,
+ systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com',
+ isResend: isResend,
+ quotationVersion: quotationVersion,
+ versionInfo: isResend ? `(버전 ${quotationVersion})` : '',
+ };
+
+ // 이메일 전송 (모든 벤더 이메일을 to 필드에 배열로 전달)
+ await sendEmail({
+ to: vendorEmailsString,
+ subject: isResend
+ ? `[RFQ 재전송] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'} ${emailContext.versionInfo}`
+ : `[RFQ] ${rfq.rfqCode} - ${rfq.item?.itemName || '견적 요청'}`,
+ template: 'rfq-notification',
+ context: emailContext,
+ cc: sender.email, // 발신자를 CC에 추가
+ });
+ }
+ }
+ });
+
+ // 캐시 무효화
+ revalidateTag(`rfqs-po`);
+
+ return {
+ success: true,
+ message: "RFQ가 성공적으로 전송되었습니다",
+ }
+ } catch (error) {
+ console.error("RFQ 전송 오류:", error);
+ return {
+ success: false,
+ message: "RFQ 전송 중 오류가 발생했습니다",
+ }
+ }
+}
+/**
+ * 첨부파일 타입 정의
+ */
+export interface Attachment {
+ id: number
+ fileName: string
+ fileSize: number
+ fileType: string | null // <- null 허용
+ filePath: string
+ uploadedAt: Date
+}
+
+/**
+ * 코멘트 타입 정의
+ */
+export interface Comment {
+ id: number
+ rfqId: number
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string
+ isVendorComment: boolean | null // null 허용으로 변경
+ createdAt: Date
+ updatedAt: Date
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: Attachment[]
+ isRead: boolean | null // null 허용으로 변경
+}
+
+
+/**
+ * 특정 RFQ와 벤더 간의 커뮤니케이션 메시지를 가져오는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ * @returns 코멘트 목록
+ */
+export async function fetchVendorComments(rfqId: number, vendorId?: number): Promise<Comment[]> {
+ if (!vendorId) {
+ return []
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 코멘트 쿼리
+ const comments = await db.query.procurementRfqComments.findMany({
+ where: and(
+ eq(procurementRfqComments.rfqId, rfqId),
+ eq(procurementRfqComments.vendorId, vendorId)
+ ),
+ orderBy: [procurementRfqComments.createdAt],
+ with: {
+ user: {
+ columns: {
+ name: true
+ }
+ },
+ vendor: {
+ columns: {
+ vendorName: true
+ }
+ },
+ attachments: true,
+ }
+ })
+
+ // 결과 매핑
+ return comments.map(comment => ({
+ id: comment.id,
+ rfqId: comment.rfqId,
+ vendorId: comment.vendorId,
+ userId: comment.userId || undefined,
+ content: comment.content,
+ isVendorComment: comment.isVendorComment,
+ createdAt: comment.createdAt,
+ updatedAt: comment.updatedAt,
+ userName: comment.user?.name,
+ vendorName: comment.vendor?.vendorName,
+ isRead: comment.isRead,
+ attachments: comment.attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ fileType: att.fileType,
+ filePath: att.filePath,
+ uploadedAt: att.uploadedAt
+ }))
+ }))
+ } catch (error) {
+ console.error('벤더 코멘트 가져오기 오류:', error)
+ throw error
+ }
+}
+
+/**
+ * 코멘트를 읽음 상태로 표시하는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ */
+export async function markMessagesAsRead(rfqId: number, vendorId?: number): Promise<void> {
+ if (!vendorId) {
+ return
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다")
+ }
+
+ // 벤더가 작성한 읽지 않은 코멘트 업데이트
+ await db.update(procurementRfqComments)
+ .set({ isRead: true })
+ .where(
+ and(
+ eq(procurementRfqComments.rfqId, rfqId),
+ eq(procurementRfqComments.vendorId, vendorId),
+ eq(procurementRfqComments.isVendorComment, true),
+ eq(procurementRfqComments.isRead, false)
+ )
+ )
+
+ // 캐시 무효화
+ revalidateTag(`rfq-${rfqId}-comments`)
+ } catch (error) {
+ console.error('메시지 읽음 표시 오류:', error)
+ throw error
+ }
+}
+
+/**
+ * 읽지 않은 메시지 개수 가져오기 서버 액션
+ *
+ * @param rfqId RFQ ID
+ */
+export async function fetchUnreadMessages(rfqId: number): Promise<Record<number, number>> {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다");
+ }
+
+ // 쿼리 빌더 방식으로 카운트 조회 - 타입 안전 방식
+ const result = await db
+ .select({
+ vendorId: procurementRfqComments.vendorId,
+ unreadCount: count()
+ })
+ .from(procurementRfqComments)
+ .where(
+ and(
+ eq(procurementRfqComments.rfqId, rfqId),
+ eq(procurementRfqComments.isVendorComment, true),
+ eq(procurementRfqComments.isRead, false)
+ )
+ )
+ .groupBy(procurementRfqComments.vendorId);
+
+ // 결과 매핑
+ const unreadMessages: Record<number, number> = {};
+ result.forEach(row => {
+ if (row.vendorId) {
+ unreadMessages[row.vendorId] = Number(row.unreadCount);
+ }
+ });
+
+ return unreadMessages;
+ } catch (error) {
+ console.error('읽지 않은 메시지 개수 가져오기 오류:', error);
+ throw error;
+ }
+}
+
+
+/**
+ * 견적서 업데이트 서버 액션
+ */
+export async function updateVendorQuotation(data: {
+ id: number
+ quotationVersion?: number
+ currency?: string
+ validUntil?: Date
+ estimatedDeliveryDate?: Date
+ paymentTermsCode?: string
+ incotermsCode?: string
+ incotermsDetail?: string
+ remark?: string
+ subTotal?: string
+ taxTotal?: string
+ discountTotal?: string
+ totalPrice?: string
+ totalItemsCount?: number
+}) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ }
+ }
+
+ // 견적서 존재 확인
+ const quotation = await db.query.procurementVendorQuotations.findFirst({
+ where: eq(procurementVendorQuotations.id, data.id),
+ })
+
+ if (!quotation) {
+ return {
+ success: false,
+ message: "견적서를 찾을 수 없습니다",
+ }
+ }
+
+ // 권한 확인 (벤더 또는 관리자만 수정 가능)
+ const isAuthorized =
+ (session.user.domain === "partners" && session.user.companyId === quotation.vendorId)
+
+ if (!isAuthorized) {
+ return {
+ success: false,
+ message: "견적서 수정 권한이 없습니다",
+ }
+ }
+
+ // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능)
+ if (quotation.status !== "Draft" && quotation.status !== "Rejected") {
+ return {
+ success: false,
+ message: "제출되었거나 승인된 견적서는 수정할 수 없습니다",
+ }
+ }
+
+ // 업데이트할 데이터 구성
+ const updateData: Record<string, any> = {
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ }
+
+ // 필드 추가
+ if (data.currency) updateData.currency = data.currency
+ if (data.validUntil) updateData.validUntil = data.validUntil
+ if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate
+ if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode
+ if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode
+ if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail
+ if (data.remark !== undefined) updateData.remark = data.remark
+ if (data.subTotal) updateData.subTotal = data.subTotal
+ if (data.taxTotal) updateData.taxTotal = data.taxTotal
+ if (data.discountTotal) updateData.discountTotal = data.discountTotal
+ if (data.totalPrice) updateData.totalPrice = data.totalPrice
+ if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount
+
+ // Rejected 상태에서 수정 시 Draft 상태로 변경
+ if (quotation.status === "Rejected") {
+ updateData.status = "Draft"
+
+ // 버전 증가
+ if (data.quotationVersion) {
+ updateData.quotationVersion = data.quotationVersion + 1
+ } else {
+ updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1
+ }
+ }
+
+ // 견적서 업데이트
+ await db.update(procurementVendorQuotations)
+ .set(updateData)
+ .where(eq(procurementVendorQuotations.id, data.id))
+
+ // 캐시 무효화
+ revalidateTag(`quotation-${data.id}`)
+ revalidateTag(`rfq-${quotation.rfqId}`)
+
+ return {
+ success: true,
+ message: "견적서가 업데이트되었습니다",
+ }
+ } catch (error) {
+ console.error("견적서 업데이트 오류:", error)
+ return {
+ success: false,
+ message: "견적서 업데이트 중 오류가 발생했습니다",
+ }
+ }
+}
+
+interface QuotationItem {
+ unitPrice: number;
+ deliveryDate: Date | null;
+ status: "Draft" | "Rejected" | "Submitted" | "Approved"; // 상태를 유니온 타입으로 정의
+
+ // 필요한 다른 속성들도 추가
+}
+
+/**
+ * 견적서 제출 서버 액션
+ */
+export async function submitVendorQuotation(data: {
+ id: number
+ quotationVersion?: number
+ currency?: string
+ validUntil?: Date
+ estimatedDeliveryDate?: Date
+ paymentTermsCode?: string
+ incotermsCode?: string
+ incotermsDetail?: string
+ remark?: string
+ subTotal?: string
+ taxTotal?: string
+ discountTotal?: string
+ totalPrice?: string
+ totalItemsCount?: number
+}) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ }
+ }
+
+ // 견적서 존재 확인
+ const quotation = await db.query.procurementVendorQuotations.findFirst({
+ where: eq(procurementVendorQuotations.id, data.id),
+ with: {
+ items: true,
+ }
+ })
+
+ if (!quotation) {
+ return {
+ success: false,
+ message: "견적서를 찾을 수 없습니다",
+ }
+ }
+
+ // 권한 확인 (벤더 또는 관리자만 제출 가능)
+ const isAuthorized =
+ (session.user.domain === "partners" && session.user.companyId === quotation.vendorId)
+
+ if (!isAuthorized) {
+ return {
+ success: false,
+ message: "견적서 제출 권한이 없습니다",
+ }
+ }
+
+ // 상태 확인 (Draft 또는 Rejected 상태만 제출 가능)
+ if (quotation.status !== "Draft" && quotation.status !== "Rejected") {
+ return {
+ success: false,
+ message: "이미 제출되었거나 승인된 견적서는 다시 제출할 수 없습니다",
+ }
+ }
+
+ // 견적 항목 검증
+ if (!quotation.items || (quotation.items as QuotationItem[]).length === 0) {
+ return {
+ success: false,
+ message: "견적 항목이 없습니다",
+ }
+ }
+
+ // 필수 항목 검증
+ const hasEmptyItems = (quotation.items as QuotationItem[]).some(item =>
+ item.unitPrice <= 0 || !item.deliveryDate
+ )
+
+ if (hasEmptyItems) {
+ return {
+ success: false,
+ message: "모든 항목의 단가와 납품일을 입력해주세요",
+ }
+ }
+
+ // 필수 정보 검증
+ if (!data.validUntil || !data.estimatedDeliveryDate) {
+ return {
+ success: false,
+ message: "견적 유효기간과 예상 납품일은 필수 항목입니다",
+ }
+ }
+
+ // 업데이트할 데이터 구성
+ const updateData: Record<string, any> = {
+ status: "Submitted",
+ submittedAt: new Date(),
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ }
+
+ // 필드 추가
+ if (data.currency) updateData.currency = data.currency
+ if (data.validUntil) updateData.validUntil = data.validUntil
+ if (data.estimatedDeliveryDate) updateData.estimatedDeliveryDate = data.estimatedDeliveryDate
+ if (data.paymentTermsCode) updateData.paymentTermsCode = data.paymentTermsCode
+ if (data.incotermsCode) updateData.incotermsCode = data.incotermsCode
+ if (data.incotermsDetail !== undefined) updateData.incotermsDetail = data.incotermsDetail
+ if (data.remark !== undefined) updateData.remark = data.remark
+ if (data.subTotal) updateData.subTotal = data.subTotal
+ if (data.taxTotal) updateData.taxTotal = data.taxTotal
+ if (data.discountTotal) updateData.discountTotal = data.discountTotal
+ if (data.totalPrice) updateData.totalPrice = data.totalPrice
+ if (data.totalItemsCount) updateData.totalItemsCount = data.totalItemsCount
+
+ // Rejected 상태에서 제출 시 버전 증가
+ if (quotation.status === "Rejected") {
+ updateData.status = "Revised"
+
+ if (data.quotationVersion) {
+ updateData.quotationVersion = data.quotationVersion + 1
+ } else {
+ updateData.quotationVersion = (quotation.quotationVersion ?? 0) + 1
+ }
+ }
+
+ // 견적서 업데이트
+ await db.update(procurementVendorQuotations)
+ .set(updateData)
+ .where(eq(procurementVendorQuotations.id, data.id))
+
+ // 캐시 무효화
+ revalidateTag(`quotation-${data.id}`)
+ revalidateTag(`rfq-${quotation.rfqId}`)
+
+ return {
+ success: true,
+ message: "견적서가 성공적으로 제출되었습니다",
+ }
+ } catch (error) {
+ console.error("견적서 제출 오류:", error)
+ return {
+ success: false,
+ message: "견적서 제출 중 오류가 발생했습니다",
+ }
+ }
+}
+
+/**
+ * 견적 항목 업데이트 서버 액션
+ */
+export async function updateQuotationItem(data: {
+ id: number
+ unitPrice?: number
+ totalPrice?: number
+ vendorMaterialCode?: string
+ vendorMaterialDescription?: string
+ deliveryDate?: Date | null
+ leadTimeInDays?: number
+ taxRate?: number
+ taxAmount?: number
+ discountRate?: number
+ discountAmount?: number
+ remark?: string
+ isAlternative?: boolean
+ isRecommended?: boolean
+}) {
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "인증이 필요합니다",
+ }
+ }
+
+ // 항목 존재 확인
+ const item = await db.query.procurementQuotationItems.findFirst({
+ where: eq(procurementQuotationItems.id, data.id),
+ with: {
+ quotation: true,
+ }
+ })
+
+ if (!item || !item.quotation) {
+ return {
+ success: false,
+ message: "견적 항목을 찾을 수 없습니다",
+ }
+ }
+
+ // 권한 확인 (벤더 또는 관리자만 수정 가능)
+ const isAuthorized = (
+ session.user.domain === "partners" &&
+ session.user.companyId === (item.quotation as { vendorId: number }).vendorId
+ )
+
+ if (!isAuthorized) {
+ return {
+ success: false,
+ message: "견적 항목 수정 권한이 없습니다",
+ }
+ }
+
+ const quotation = item.quotation as Quotation;
+
+ // 상태 확인 (Draft 또는 Rejected 상태만 수정 가능)
+ if (quotation.status !== "Draft" && quotation.status !== "Rejected") {
+ return {
+ success: false,
+ message: "제출되었거나 승인된 견적서의 항목은 수정할 수 없습니다",
+ }
+ }
+
+ // 업데이트할 데이터 구성
+ const updateData: Record<string, any> = {
+ updatedAt: new Date(),
+ }
+
+ // 필드 추가
+ if (data.unitPrice !== undefined) updateData.unitPrice = data.unitPrice
+ if (data.totalPrice !== undefined) updateData.totalPrice = data.totalPrice
+ if (data.vendorMaterialCode !== undefined) updateData.vendorMaterialCode = data.vendorMaterialCode
+ if (data.vendorMaterialDescription !== undefined) updateData.vendorMaterialDescription = data.vendorMaterialDescription
+ if (data.deliveryDate !== undefined) updateData.deliveryDate = data.deliveryDate
+ if (data.leadTimeInDays !== undefined) updateData.leadTimeInDays = data.leadTimeInDays
+ if (data.taxRate !== undefined) updateData.taxRate = data.taxRate
+ if (data.taxAmount !== undefined) updateData.taxAmount = data.taxAmount
+ if (data.discountRate !== undefined) updateData.discountRate = data.discountRate
+ if (data.discountAmount !== undefined) updateData.discountAmount = data.discountAmount
+ if (data.remark !== undefined) updateData.remark = data.remark
+ if (data.isAlternative !== undefined) updateData.isAlternative = data.isAlternative
+ if (data.isRecommended !== undefined) updateData.isRecommended = data.isRecommended
+
+ // 항목 업데이트
+ await db.update(procurementQuotationItems)
+ .set(updateData)
+ .where(eq(procurementQuotationItems.id, data.id))
+
+ // 캐시 무효화
+ revalidateTag(`quotation-${item.quotationId}`)
+
+ return {
+ success: true,
+ message: "견적 항목이 업데이트되었습니다",
+ }
+ } catch (error) {
+ console.error("견적 항목 업데이트 오류:", error)
+ return {
+ success: false,
+ message: "견적 항목 업데이트 중 오류가 발생했습니다",
+ }
+ }
+}
+
+
+// Quotation 상태 타입 정의
+export type QuotationStatus = "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted";
+
+// 인터페이스 정의
+export interface Quotation {
+ id: number;
+ quotationCode: string;
+ status: QuotationStatus;
+ totalPrice: string;
+ currency: string;
+ submittedAt: string | null;
+ validUntil: string | null;
+ vendorId: number;
+ rfq?: {
+ rfqCode: string;
+ } | null;
+ vendor?: any;
+}
+
+
+/**
+ * 벤더별 견적서 목록 조회
+ */
+export async function getVendorQuotations(input: GetQuotationsSchema, vendorId: string) {
+ return unstable_cache(
+ async () => {
+ try {
+ // 페이지네이션 설정
+ const page = input.page || 1;
+ const perPage = input.perPage || 10;
+ const offset = (page - 1) * perPage;
+
+ // 필터링 설정
+ // advancedTable 모드로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: procurementVendorQuotations,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 글로벌 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(procurementVendorQuotations.quotationCode, s),
+ ilike(procurementVendorQuotations.status, s),
+ ilike(procurementVendorQuotations.totalPrice, s)
+ );
+ }
+
+ // 벤더 ID 조건
+ const vendorIdWhere = vendorId ?
+ eq(procurementVendorQuotations.vendorId, Number(vendorId)) :
+ undefined;
+
+ // 모든 조건 결합
+ let whereConditions = [];
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+ if (vendorIdWhere) whereConditions.push(vendorIdWhere);
+
+ // 최종 조건
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+ // 정렬 설정
+ const orderBy = input.sort && input.sort.length > 0
+ ? input.sort.map((item) => {
+ // @ts-ignore - 동적 속성 접근
+ return item.desc ? desc(procurementVendorQuotations[item.id]) : asc(procurementVendorQuotations[item.id]);
+ })
+ : [asc(procurementVendorQuotations.updatedAt)];
+
+ // 쿼리 실행
+ const quotations = await db.query.procurementVendorQuotations.findMany({
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: perPage,
+ with: {
+ rfq: true,
+ vendor: true,
+ }
+ });
+
+ // 전체 개수 조회
+ const { totalCount } = await db
+ .select({ totalCount: count() })
+ .from(procurementVendorQuotations)
+ .where(finalWhere || undefined)
+ .then(rows => rows[0]);
+
+ console.log(totalCount)
+
+ // 페이지 수 계산
+ const pageCount = Math.ceil(Number(totalCount) / perPage);
+
+ return {
+ data: quotations as Quotation[],
+ pageCount
+ };
+ } catch (err) {
+ console.error("getVendorQuotations 에러:", err);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [`vendor-quotations-${vendorId}-${JSON.stringify(input)}`],
+ {
+ revalidate: 3600,
+ tags: [`vendor-quotations-${vendorId}`],
+ }
+ )();
+}
+
+/**
+ * 견적서 상태별 개수 조회
+ */
+export async function getQuotationStatusCounts(vendorId: string) {
+ return unstable_cache(
+ async () => {
+ try {
+ const initial: Record<QuotationStatus, number> = {
+ Draft: 0,
+ Submitted: 0,
+ Revised: 0,
+ Rejected: 0,
+ Accepted: 0,
+ };
+
+ // 벤더 ID 조건
+ const whereCondition = vendorId ?
+ eq(procurementVendorQuotations.vendorId, Number(vendorId)) :
+ undefined;
+
+ // 상태별 그룹핑 쿼리
+ const rows = await db
+ .select({
+ status: procurementVendorQuotations.status,
+ count: count(),
+ })
+ .from(procurementVendorQuotations)
+ .where(whereCondition)
+ .groupBy(procurementVendorQuotations.status);
+
+ // 결과 처리
+ const result = rows.reduce<Record<QuotationStatus, number>>((acc, { status, count }) => {
+ if (status) {
+ acc[status as QuotationStatus] = Number(count);
+ }
+ return acc;
+ }, initial);
+
+ return result;
+ } catch (err) {
+ console.error("getQuotationStatusCounts 에러:", err);
+ return {} as Record<QuotationStatus, number>;
+ }
+ },
+ [`quotation-status-counts-${vendorId}`],
+ {
+ revalidate: 3600,
+ }
+ )();
+}
+
+
+/**
+ * 벤더 입장에서 구매자와의 커뮤니케이션 메시지를 가져오는 서버 액션
+ *
+ * @param rfqId RFQ ID
+ * @param vendorId 벤더 ID
+ * @returns 코멘트 목록
+ */
+export async function fetchBuyerVendorComments(rfqId: number, vendorId: number): Promise<Comment[]> {
+ if (!rfqId || !vendorId) {
+ return [];
+ }
+
+ try {
+ // 인증 확인
+ const session = await getServerSession(authOptions);
+
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다");
+ }
+
+ // 벤더 접근 권한 확인 (벤더 사용자이며 해당 벤더의 ID와 일치해야 함)
+ if (
+ session.user.domain === "partners" &&
+ ((session.user.companyId ?? 0) !== Number(vendorId))
+ ) {
+ throw new Error("접근 권한이 없습니다");
+ }
+
+ // 코멘트 쿼리
+ const comments = await db.query.procurementRfqComments.findMany({
+ where: and(
+ eq(procurementRfqComments.rfqId, rfqId),
+ eq(procurementRfqComments.vendorId, vendorId)
+ ),
+ orderBy: [procurementRfqComments.createdAt],
+ with: {
+ user: {
+ columns: {
+ name: true
+ }
+ },
+ vendor: {
+ columns: {
+ vendorName: true
+ }
+ },
+ attachments: true,
+ }
+ });
+
+ // 벤더가 접근하는 경우, 벤더 메시지를 읽음 상태로 표시
+ if (session.user.domain === "partners") {
+ // 읽지 않은 구매자 메시지를 읽음 상태로 업데이트
+ await db.update(procurementRfqComments)
+ .set({ isRead: true })
+ .where(
+ and(
+ eq(procurementRfqComments.rfqId, rfqId),
+ eq(procurementRfqComments.vendorId, vendorId),
+ eq(procurementRfqComments.isVendorComment, false), // 구매자가 보낸 메시지
+ eq(procurementRfqComments.isRead, false)
+ )
+ )
+ .execute();
+ }
+
+ // 결과 매핑
+ return comments.map(comment => ({
+ id: comment.id,
+ rfqId: comment.rfqId,
+ vendorId: comment.vendorId,
+ userId: comment.userId || undefined,
+ content: comment.content,
+ isVendorComment: comment.isVendorComment,
+ createdAt: comment.createdAt,
+ updatedAt: comment.updatedAt,
+ userName: comment.user?.name,
+ vendorName: comment.vendor?.vendorName,
+ isRead: comment.isRead,
+ attachments: comment.attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ fileType: att.fileType,
+ filePath: att.filePath,
+ uploadedAt: att.uploadedAt
+ }))
+ }));
+ } catch (error) {
+ console.error('벤더-구매자 커뮤니케이션 가져오기 오류:', error);
+ throw error;
+ }
+}
+
+
+const getRandomProject = async () => {
+ const allProjects = await db.select().from(projects).limit(10);
+ const randomIndex = Math.floor(Math.random() * allProjects.length);
+ return allProjects[randomIndex] || null;
+};
+
+const getRandomItem = async () => {
+ const allItems = await db.select().from(items).limit(10);
+ const randomIndex = Math.floor(Math.random() * allItems.length);
+ return allItems[randomIndex] || null;
+};
+
+// 외부 시스템에서 RFQ 가져오기 서버 액션
+export async function fetchExternalRfqs() {
+ try {
+ // 현재 로그인한 사용자의 세션 정보 가져오기
+ const session = await getServerSession(authOptions);
+
+ if (!session || !session.user || !session.user.id) {
+ return {
+ success: false,
+ message: '인증된 사용자 정보를 찾을 수 없습니다'
+ };
+ }
+
+ const userId = session.user.id;
+
+ const randomProject = await getRandomProject();
+ const randomItem = await getRandomItem();
+
+ if (!randomProject || !randomItem) {
+ return {
+ success: false,
+ message: '임의 데이터를 생성하는 데 필요한 기본 데이터가 없습니다'
+ };
+ }
+
+ // 현재 날짜 기준 임의 날짜 생성
+ const today = new Date();
+ const dueDate = new Date(today);
+ dueDate.setDate(today.getDate() + Math.floor(Math.random() * 30) + 15); // 15-45일 후
+
+
+ // RFQ 코드 생성 (현재 연도 + 3자리 숫자)
+ const currentYear = today.getFullYear();
+ const randomNum = Math.floor(Math.random() * 900) + 100; // 100-999
+ const rfqCode = `R${currentYear}${randomNum}`;
+ const seriesOptions = ["SS", "II", ""];
+ const randomSeriesIndex = Math.floor(Math.random() * seriesOptions.length);
+ const seriesValue = seriesOptions[randomSeriesIndex];
+
+ // RFQ 생성 - 로그인한 사용자 ID 사용
+ const newRfq = await db.insert(procurementRfqs).values({
+ rfqCode,
+ projectId: randomProject.id,
+ series:seriesValue,
+ itemId: randomItem.id,
+ dueDate,
+ rfqSendDate: null, // null로 설정/
+ status: "RFQ Created",
+ rfqSealedYn: false,
+ picCode: `PIC-${Math.floor(Math.random() * 1000)}`,
+ remark: "테스트용으로 아무말이나 들어간 것으로 실제로는 SAP에 있는 값이 옵니다. 오해 ㄴㄴ",
+ createdBy: userId,
+ updatedBy: userId,
+ }).returning();
+
+ if (newRfq.length === 0) {
+ return {
+ success: false,
+ message: 'RFQ 생성에 실패했습니다'
+ };
+ }
+
+ // PR 항목 생성 (1-3개 임의 생성)
+ const prItemsCount = Math.floor(Math.random() * 3) + 1;
+ const createdPrItems = [];
+
+ for (let i = 0; i < prItemsCount; i++) {
+ const deliveryDate = new Date(today);
+ deliveryDate.setDate(today.getDate() + Math.floor(Math.random() * 60) + 30); // 30-90일 후
+
+ const randomTwoDigits = String(Math.floor(Math.random() * 100)).padStart(2, '0');
+ // 프로젝트와 아이템 코드가 있다고 가정하고, 없을 경우 기본값 사용
+ const projectCode = randomProject.code || 'PROJ';
+ const itemCode = randomItem.itemCode || 'ITEM';
+ const materialCode = `${projectCode}${itemCode}${randomTwoDigits}`;
+ const isMajor = i === 0 ? true : Math.random() > 0.7;
+
+ const newPrItem = await db.insert(prItems).values({
+ procurementRfqsId: newRfq[0].id,
+ rfqItem: `RFQI-${Math.floor(Math.random() * 1000)}`,
+ prItem: `PRI-${Math.floor(Math.random() * 1000)}`,
+ prNo: `PRN-${Math.floor(Math.random() * 1000)}`,
+ itemId: randomItem.id,
+ materialCode,
+ materialCategory: "Standard",
+ acc: `ACC-${Math.floor(Math.random() * 100)}`,
+ materialDescription: `${['알루미늄', '구리', '철', '실리콘'][Math.floor(Math.random() * 4)]} 재질 부품`,
+ size: `${Math.floor(Math.random() * 100) + 10}x${Math.floor(Math.random() * 100) + 10}`,
+ deliveryDate,
+ quantity: Math.floor(Math.random() * 100) + 1,
+ uom: ['EA', 'KG', 'M', 'L'][Math.floor(Math.random() * 4)],
+ grossWeight: Math.floor(Math.random() * 1000) / 10,
+ gwUom: ['KG', 'T'][Math.floor(Math.random() * 2)],
+ specNo: `SPEC-${Math.floor(Math.random() * 1000)}`,
+ majorYn:isMajor, // 30% 확률로 true
+ remark: "외부 시스템에서 가져온 PR 항목",
+ }).returning();
+
+ createdPrItems.push(newPrItem[0]);
+ }
+
+ revalidateTag(`rfqs-po`)
+
+ return {
+ success: true,
+ message: '외부 RFQ를 성공적으로 가져왔습니다',
+ data: {
+ rfq: newRfq[0],
+ prItems: createdPrItems
+ }
+ };
+
+ } catch (error) {
+ console.error('외부 RFQ 가져오기 오류:', error);
+ return {
+ success: false,
+ message: '외부 RFQ를 가져오는 중 오류가 발생했습니다'
+ };
+ }
+}
+
+/**
+ * RFQ ID에 해당하는 모든 벤더 견적 정보를 조회하는 서버 액션
+ * @param rfqId RFQ ID
+ * @returns 견적 정보 목록
+ */
+export async function fetchVendorQuotations(rfqId: number) {
+ try {
+ // 벤더 정보와 함께 견적 정보 조회
+ const quotations = await db
+ .select({
+ // 견적 기본 정보
+ id: procurementVendorQuotations.id,
+ rfqId: procurementVendorQuotations.rfqId,
+ vendorId: procurementVendorQuotations.vendorId,
+ quotationCode: procurementVendorQuotations.quotationCode,
+ quotationVersion: procurementVendorQuotations.quotationVersion,
+ totalItemsCount: procurementVendorQuotations.totalItemsCount,
+ subTotal: procurementVendorQuotations.subTotal,
+ taxTotal: procurementVendorQuotations.taxTotal,
+ discountTotal: procurementVendorQuotations.discountTotal,
+ totalPrice: procurementVendorQuotations.totalPrice,
+ currency: procurementVendorQuotations.currency,
+ validUntil: procurementVendorQuotations.validUntil,
+ estimatedDeliveryDate: procurementVendorQuotations.estimatedDeliveryDate,
+ paymentTermsCode: procurementVendorQuotations.paymentTermsCode,
+ incotermsCode: procurementVendorQuotations.incotermsCode,
+ incotermsDetail: procurementVendorQuotations.incotermsDetail,
+ status: procurementVendorQuotations.status,
+ remark: procurementVendorQuotations.remark,
+ rejectionReason: procurementVendorQuotations.rejectionReason,
+ submittedAt: procurementVendorQuotations.submittedAt,
+ acceptedAt: procurementVendorQuotations.acceptedAt,
+ createdAt: procurementVendorQuotations.createdAt,
+ updatedAt: procurementVendorQuotations.updatedAt,
+
+ // 벤더 정보
+ vendorName: vendors.vendorName,
+ paymentTermsDescription: paymentTerms.description,
+ incotermsDescription: incoterms.description,
+ })
+ .from(procurementVendorQuotations)
+ .leftJoin(vendors, eq(procurementVendorQuotations.vendorId, vendors.id))
+ .leftJoin(paymentTerms, eq(procurementVendorQuotations.paymentTermsCode, paymentTerms.code))
+ .leftJoin(incoterms, eq(procurementVendorQuotations.incotermsCode, incoterms.code))
+ .where(
+ and(
+ eq(procurementVendorQuotations.rfqId, rfqId),
+ // eq(procurementVendorQuotations.status, "Submitted") // <=== Submitted 상태만!
+ )
+ )
+ .orderBy(desc(procurementVendorQuotations.updatedAt))
+
+
+ return { success: true, data: quotations }
+ } catch (error) {
+ console.error("벤더 견적 조회 오류:", error)
+ return { success: false, error: "벤더 견적을 조회하는 중 오류가 발생했습니다" }
+ }
+}
+
+/**
+ * 견적 ID 목록에 해당하는 모든 견적 아이템 정보를 조회하는 서버 액션
+ * @param quotationIds 견적 ID 배열
+ * @returns 견적 아이템 정보 목록
+ */
+export async function fetchQuotationItems(quotationIds: number[]) {
+ try {
+ // 빈 배열이 전달된 경우 빈 결과 반환
+ if (!quotationIds.length) {
+ return { success: true, data: [] }
+ }
+
+ // 견적 아이템 정보 조회
+ const items = await db
+ .select()
+ .from(procurementQuotationItems)
+ .where(inArray(procurementQuotationItems.quotationId, quotationIds))
+ .orderBy(procurementQuotationItems.id)
+
+ return { success: true, data: items }
+ } catch (error) {
+ console.error("견적 아이템 조회 오류:", error)
+ return { success: false, error: "견적 아이템을 조회하는 중 오류가 발생했습니다" }
+ }
+}
diff --git a/lib/procurement-rfqs/table/pr-item-dialog.tsx b/lib/procurement-rfqs/table/pr-item-dialog.tsx
new file mode 100644
index 00000000..4523295d
--- /dev/null
+++ b/lib/procurement-rfqs/table/pr-item-dialog.tsx
@@ -0,0 +1,258 @@
+"use client";
+
+import * as React from "react";
+import { useState, useEffect } from "react";
+import { formatDate } from "@/lib/utils";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Badge } from "@/components/ui/badge";
+import { ProcurementRfqsView } from "@/db/schema";
+import { fetchPrItemsByRfqId } from "../services";
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Input } from "@/components/ui/input";
+import { Search } from "lucide-react";
+
+// PR 항목 타입 정의
+interface PrItemView {
+ id: number;
+ procurementRfqsId: number;
+ rfqItem: string | null;
+ prItem: string | null;
+ prNo: string | null;
+ itemId: number | null;
+ materialCode: string | null;
+ materialCategory: string | null;
+ acc: string | null;
+ materialDescription: string | null;
+ size: string | null;
+ deliveryDate: Date | null;
+ quantity: number | null;
+ uom: string | null;
+ grossWeight: number | null;
+ gwUom: string | null;
+ specNo: string | null;
+ specUrl: string | null;
+ trackingNo: string | null;
+ majorYn: boolean | null;
+ projectDef: string | null;
+ projectSc: string | null;
+ projectKl: string | null;
+ projectLc: string | null;
+ projectDl: string | null;
+ remark: string | null;
+ rfqCode: string | null;
+ itemCode: string | null;
+ itemName: string | null;
+}
+
+interface PrDetailsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfq: ProcurementRfqsView | null;
+}
+
+export function PrDetailsDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: PrDetailsDialogProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const [prItems, setPrItems] = useState<PrItemView[]>([]);
+ const [searchTerm, setSearchTerm] = useState("");
+
+ // 검색어로 필터링된 항목들
+ const filteredItems = React.useMemo(() => {
+ if (!searchTerm.trim()) return prItems;
+
+ const term = searchTerm.toLowerCase();
+ return prItems.filter(item =>
+ (item.materialDescription || "").toLowerCase().includes(term) ||
+ (item.materialCode || "").toLowerCase().includes(term) ||
+ (item.prNo || "").toLowerCase().includes(term) ||
+ (item.prItem || "").toLowerCase().includes(term) ||
+ (item.rfqItem || "").toLowerCase().includes(term)
+ );
+ }, [prItems, searchTerm]);
+
+ // 선택된 RFQ가 변경되면 PR 항목 데이터를 가져옴
+ useEffect(() => {
+ async function loadPrItems() {
+ if (!selectedRfq || !open) {
+ setPrItems([]);
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ const result = await fetchPrItemsByRfqId(selectedRfq.id);
+ const mappedItems: PrItemView[] = result.data.map(item => ({
+ ...item,
+ // procurementRfqsId가 null이면 selectedRfq.id 사용
+ procurementRfqsId: item.procurementRfqsId ?? selectedRfq.id,
+ // 기타 필요한 필드에 대한 기본값 처리
+ rfqItem: item.rfqItem ?? null,
+ prItem: item.prItem ?? null,
+ prNo: item.prNo ?? null,
+ // 다른 필드도 필요에 따라 추가
+ }));
+
+ setPrItems(mappedItems);
+ } catch (error) {
+ console.error("PR 항목 로드 오류:", error);
+ setPrItems([]);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ if (open) {
+ loadPrItems();
+ setSearchTerm("");
+ }
+ }, [selectedRfq, open]);
+
+ // 선택된 RFQ가 없는 경우
+ if (!selectedRfq) {
+ return null;
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-screen-sm max-h-[90vh] flex flex-col" style={{ maxWidth: "70vw" }}>
+ <DialogHeader>
+ <DialogTitle className="text-xl">
+ PR 상세 정보 - {selectedRfq.rfqCode}
+ </DialogTitle>
+ <DialogDescription>
+ 프로젝트: {selectedRfq.projectName} ({selectedRfq.projectCode}) | 건수:{" "}
+ {selectedRfq.prItemsCount || 0}건
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="py-4 space-y-3">
+ <Skeleton className="h-8 w-full" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-24 w-full" />
+ </div>
+ ) : (
+ <div className="flex-1 flex flex-col">
+ {/* 검색 필드 */}
+ <div className="mb-4 relative">
+ <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
+ <Search className="h-4 w-4 text-muted-foreground" />
+ </div>
+ <Input
+ placeholder="PR 번호, 자재 코드, 설명 등 검색..."
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ className="pl-8"
+ />
+</div>
+ {filteredItems.length === 0 ? (
+ <div className="flex items-center justify-center py-8 text-muted-foreground border rounded-md">
+ {prItems.length === 0 ? "PR 항목이 없습니다" : "검색 결과가 없습니다"}
+ </div>
+ ) : (
+ <div className="rounded-md border flex-1 overflow-hidden">
+ <div className="overflow-x-auto" style={{ width: "100%" }}>
+ <Table style={{ minWidth: "2500px" }}>
+ <TableCaption>
+ 총 {filteredItems.length}개 항목 (전체 {prItems.length}개 중)
+ </TableCaption>
+ <TableHeader className="bg-muted/50 sticky top-0">
+ <TableRow>
+ <TableHead className="w-[100px] whitespace-nowrap">RFQ Item</TableHead>
+ <TableHead className="w-[120px] whitespace-nowrap">PR 번호</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">PR Item</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">자재그룹</TableHead>
+ <TableHead className="w-[120px] whitespace-nowrap">자재 코드</TableHead>
+ <TableHead className="w-[120px] whitespace-nowrap">자재 카테고리</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">ACC</TableHead>
+ <TableHead className="min-w-[200px] whitespace-nowrap">자재 설명</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">규격</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">납품일</TableHead>
+ <TableHead className="w-[80px] whitespace-nowrap">수량</TableHead>
+ <TableHead className="w-[80px] whitespace-nowrap">UOM</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">총중량</TableHead>
+ <TableHead className="w-[80px] whitespace-nowrap">중량 단위</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">사양 번호</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">사양 URL</TableHead>
+ <TableHead className="w-[120px] whitespace-nowrap">추적 번호</TableHead>
+ <TableHead className="w-[80px] whitespace-nowrap">주요 항목</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DEF</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 SC</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 KL</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 LC</TableHead>
+ <TableHead className="w-[100px] whitespace-nowrap">프로젝트 DL</TableHead>
+ <TableHead className="w-[150px] whitespace-nowrap">비고</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {filteredItems.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="whitespace-nowrap">{item.rfqItem || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.prNo || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.prItem || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.itemCode || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.materialCode || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.materialCategory || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.acc || "-"}</TableCell>
+ <TableCell>{item.materialDescription || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.size || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">
+ {item.deliveryDate ? formatDate(item.deliveryDate) : "-"}
+ </TableCell>
+ <TableCell className="whitespace-nowrap">{item.quantity || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.uom || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.grossWeight || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.gwUom || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.specNo || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.specUrl || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.trackingNo || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">
+ {item.majorYn ? (
+ <Badge variant="secondary">주요</Badge>
+ ) : (
+ "아니오"
+ )}
+ </TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectDef || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectSc || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectKl || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectLc || "-"}</TableCell>
+ <TableCell className="whitespace-nowrap">{item.projectDl || "-"}</TableCell>
+ <TableCell className="text-sm">{item.remark || "-"}</TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+
+ <DialogFooter className="mt-2">
+ <Button onClick={() => onOpenChange(false)}>닫기</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table-column.tsx b/lib/procurement-rfqs/table/rfq-table-column.tsx
new file mode 100644
index 00000000..3cf06315
--- /dev/null
+++ b/lib/procurement-rfqs/table/rfq-table-column.tsx
@@ -0,0 +1,373 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+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 { ProcurementRfqsView } from "@/db/schema"
+import { Check, Pencil, X } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { toast } from "sonner"
+import { Input } from "@/components/ui/input"
+import { updateRfqRemark } from "../services"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProcurementRfqsView> | null>>;
+ // 상태와 상태 설정 함수를 props로 받음
+ editingCell: EditingCellState | null;
+ setEditingCell: (state: EditingCellState | null) => void;
+ updateRemark: (rfqId: number, remark: string) => Promise<void>;
+}
+
+export interface EditingCellState {
+ rowId: string | number;
+ value: string;
+}
+
+
+export function getColumns({
+ setRowAction,
+ editingCell,
+ setEditingCell,
+ updateRemark,
+}: GetColumnsProps): ColumnDef<ProcurementRfqsView>[] {
+
+
+
+ return [
+ {
+ id: "select",
+ // Remove the "Select all" checkbox in header since we're doing single-select
+ header: () => <span className="sr-only">Select</span>,
+ cell: ({ row, table }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // If selecting this row
+ if (value) {
+ // First deselect all rows (to ensure single selection)
+ table.toggleAllRowsSelected(false)
+ // Then select just this row
+ row.toggleSelected(true)
+ // Trigger the same action that was in the "Select" button
+ setRowAction({ row, type: "select" })
+ } else {
+ // Just deselect this row
+ row.toggleSelected(false)
+ }
+ }}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ enableResizing: false,
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ },
+
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="status" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("status")}</div>,
+ meta: {
+ excelHeader: "status"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "projectCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("projectCode")}</div>,
+ meta: {
+ excelHeader: "프로젝트"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "series",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="시리즈" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("series")}</div>,
+ meta: {
+ excelHeader: "시리즈"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "rfqSealedYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 밀봉" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("rfqSealedYn") ? "Y":"N"}</div>,
+ meta: {
+ excelHeader: "RFQ 밀봉"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 100,
+ },
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ NO." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("rfqCode")}</div>,
+ meta: {
+ excelHeader: "RFQ NO."
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "po_no",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="대표 PR NO." />
+ ),
+ cell: ({ row }) => <div>{row.getValue("po_no")}</div>,
+ meta: {
+ excelHeader: "대표 PR NO."
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재그룹" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("itemCode")}</div>,
+ meta: {
+ excelHeader: "자재그룹"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "majorItemMaterialCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("majorItemMaterialCode")}</div>,
+ meta: {
+ excelHeader: "자재코드"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 120,
+ },
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재명" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("itemName")}</div>,
+ meta: {
+ excelHeader: "자재명"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "prItemsCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PR 건수" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("prItemsCount")}</div>,
+ meta: {
+ excelHeader: "PR 건수"
+ },
+ enableResizing: true,
+ // size: 80,
+ },
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 전송일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "RFQ 전송일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "earliestQuotationSubmittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첫회신 접수일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "첫회신 접수일"
+ },
+ enableResizing: true,
+ // size: 140,
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "RFQ 마감일"
+ },
+ enableResizing: true,
+ minSize: 80,
+ size: 120,
+ },
+ {
+ accessorKey: "sentByUserName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 요청자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("sentByUserName")}</div>,
+ meta: {
+ excelHeader: "RFQ 요청자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Updated At" />
+ ),
+ cell: ({ cell }) => formatDateTime(cell.getValue() as Date, "KR"),
+ meta: {
+ excelHeader: "updated At"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+
+ {
+ accessorKey: "remark",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => {
+ const rowId = row.id
+ const value = row.getValue("remark") as string
+ const isEditing = editingCell && editingCell.rowId === rowId
+
+ const startEditing = () => {
+ setEditingCell({
+ rowId,
+ value: value || ""
+ })
+ }
+
+ const cancelEditing = () => {
+ setEditingCell(null)
+ }
+
+ const saveChanges = async () => {
+ if (!editingCell) return
+
+ try {
+
+ // 컴포넌트에서 전달받은 업데이트 함수 사용
+ await updateRemark(row.original.id, editingCell.value)
+ row.original.remark = editingCell.value;
+
+ // 편집 모드 종료
+ setEditingCell(null)
+ } catch (error) {
+ console.error("비고 업데이트 오류:", error)
+ }
+ }
+
+ // 키보드 이벤트 처리
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ saveChanges()
+ } else if (e.key === "Escape") {
+ cancelEditing()
+ }
+ }
+
+ if (isEditing) {
+ return (
+ <div className="flex items-center space-x-1">
+ <Input
+ value={editingCell.value}
+ onChange={(e) => setEditingCell({
+ ...editingCell,
+ value: e.target.value
+ })}
+ onKeyDown={handleKeyDown}
+ autoFocus
+ className="h-8 w-full"
+ />
+ <div className="flex items-center">
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={saveChanges}
+ className="h-7 w-7"
+ >
+ <Check className="h-4 w-4 text-green-500" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={cancelEditing}
+ className="h-7 w-7"
+ >
+ <X className="h-4 w-4 text-red-500" />
+ </Button>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div
+ className="flex items-center justify-between group"
+ onDoubleClick={startEditing} // 더블클릭 이벤트 추가
+ >
+ <div className="truncate">{value || "-"}</div>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={startEditing}
+ className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+ <Pencil className="h-3.5 w-3.5 text-muted-foreground" />
+ </Button>
+ </div>
+ )
+ },
+ meta: {
+ excelHeader: "비고"
+ },
+ enableResizing: true,
+ size: 200,
+ }
+ ]
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx b/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx
new file mode 100644
index 00000000..26725797
--- /dev/null
+++ b/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx
@@ -0,0 +1,279 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { ClipboardList, Download, Send, Lock, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { ProcurementRfqsView } from "@/db/schema"
+import { PrDetailsDialog } from "./pr-item-dialog"
+import { sealRfq, sendRfq, getPORfqs, fetchExternalRfqs } from "../services"
+
+// total 필드 추가하여 타입 정의 수정
+type PORfqsReturn = Awaited<ReturnType<typeof getPORfqs>>
+
+interface RFQTableToolbarActionsProps {
+ table: Table<ProcurementRfqsView>;
+ // 타입 수정
+ localData?: PORfqsReturn;
+ setLocalData?: React.Dispatch<React.SetStateAction<PORfqsReturn>>;
+ onSuccess?: () => void;
+}
+
+export function RFQTableToolbarActions({
+ table,
+ localData,
+ setLocalData,
+ onSuccess
+}: RFQTableToolbarActionsProps) {
+ // 다이얼로그 열림/닫힘 상태 관리
+ const [dialogOpen, setDialogOpen] = React.useState(false)
+ const [isProcessing, setIsProcessing] = React.useState(false)
+
+ // 선택된 RFQ 가져오기
+ const getSelectedRfq = (): ProcurementRfqsView | null => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 1) {
+ return selectedRows[0].original
+ }
+ return null
+ }
+
+ // 선택된 RFQ
+ const selectedRfq = getSelectedRfq()
+
+ // PR 상세보기 버튼 클릭 핸들러
+ const handleViewPrDetails = () => {
+ const rfq = getSelectedRfq()
+ if (!rfq) {
+ toast.warning("RFQ를 선택해주세요")
+ return
+ }
+
+ if (!rfq.prItemsCount || rfq.prItemsCount <= 0) {
+ toast.warning("선택한 RFQ에 PR 항목이 없습니다")
+ return
+ }
+
+ setDialogOpen(true)
+ }
+
+ // RFQ 밀봉 버튼 클릭 핸들러
+ const handleSealRfq = async () => {
+ const rfq = getSelectedRfq()
+ if (!rfq) {
+ toast.warning("RFQ를 선택해주세요")
+ return
+ }
+
+ // 이미 밀봉된 RFQ인 경우
+ if (rfq.rfqSealedYn) {
+ toast.warning("이미 밀봉된 RFQ입니다")
+ return
+ }
+
+ try {
+ setIsProcessing(true)
+
+ // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신)
+ if (localData?.data && setLocalData) {
+ // 로컬 데이터에서 해당 행 찾기
+ const rowIndex = localData.data.findIndex(row => row.id === rfq.id);
+ if (rowIndex >= 0) {
+ // 불변성을 유지하면서 로컬 데이터 업데이트 - 타입 안전하게 복사
+ const newData = [...localData.data] as ProcurementRfqsView[];
+ newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: "Y" };
+
+ // 전체 데이터 구조 복사하여 업데이트, total 필드가 있다면 유지
+ setLocalData({
+ ...localData,
+ data: newData ?? [],
+ pageCount: localData.pageCount,
+ total: localData.total ?? 0
+ });
+ }
+ }
+
+ const result = await sealRfq(rfq.id)
+
+ if (result.success) {
+ toast.success("RFQ가 성공적으로 밀봉되었습니다")
+ // 데이터 리프레시
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "RFQ 밀봉 중 오류가 발생했습니다")
+
+ // 서버 요청 실패 시 낙관적 업데이트 되돌리기
+ if (localData?.data && setLocalData) {
+ const rowIndex = localData.data.findIndex(row => row.id === rfq.id);
+ if (rowIndex >= 0) {
+ const newData = [...localData.data] as ProcurementRfqsView[];
+ newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원
+ setLocalData({
+ ...localData,
+ data: newData ?? [],
+ pageCount: localData.pageCount,
+ total: localData.total ?? 0
+ });
+ }
+ }
+ }
+ } catch (error) {
+ console.error("RFQ 밀봉 오류:", error)
+ toast.error("RFQ 밀봉 중 오류가 발생했습니다")
+
+ // 에러 발생 시 낙관적 업데이트 되돌리기
+ if (localData?.data && setLocalData) {
+ const rowIndex = localData.data.findIndex(row => row.id === rfq.id);
+ if (rowIndex >= 0) {
+ const newData = [...localData.data] as ProcurementRfqsView[];
+ newData[rowIndex] = { ...newData[rowIndex], rfqSealedYn: rfq.rfqSealedYn }; // 원래 값으로 복원
+ setLocalData({
+ ...localData,
+ data: newData ?? [],
+ pageCount: localData.pageCount,
+ total: localData.total ?? 0
+ });
+ }
+ }
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ // RFQ 전송 버튼 클릭 핸들러
+ const handleSendRfq = async () => {
+ const rfq = getSelectedRfq()
+ if (!rfq) {
+ toast.warning("RFQ를 선택해주세요")
+ return
+ }
+
+ // 전송 가능한 상태인지 확인
+ if (rfq.status !== "RFQ Vendor Assignned" && rfq.status !== "RFQ Sent") {
+ toast.warning("벤더가 할당된 RFQ이거나 전송한 적이 있는 RFQ만 전송할 수 있습니다")
+ return
+ }
+
+ try {
+ setIsProcessing(true)
+
+ const result = await sendRfq(rfq.id)
+
+ if (result.success) {
+ toast.success("RFQ가 성공적으로 전송되었습니다")
+ // 데이터 리프레시
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "RFQ 전송 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("RFQ 전송 오류:", error)
+ toast.error("RFQ 전송 중 오류가 발생했습니다")
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ const handleFetchExternalRfqs = async () => {
+ try {
+ setIsProcessing(true);
+
+ const result = await fetchExternalRfqs();
+
+ if (result.success) {
+ toast.success(result.message || "외부 RFQ를 성공적으로 가져왔습니다");
+ // 데이터 리프레시
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "외부 RFQ를 가져오는 중 오류가 발생했습니다");
+ }
+ } catch (error) {
+ console.error("외부 RFQ 가져오기 오류:", error);
+ toast.error("외부 RFQ를 가져오는 중 오류가 발생했습니다");
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "rfq",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ {/* RFQ 가져오기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleFetchExternalRfqs}
+ className="gap-2"
+ disabled={isProcessing}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">RFQ 가져오기</span>
+ </Button>
+
+ {/* PR 상세보기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleViewPrDetails}
+ className="gap-2"
+ disabled={!selectedRfq || !(selectedRfq.prItemsCount && selectedRfq.prItemsCount > 0)}
+ >
+ <ClipboardList className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">PR 상세보기</span>
+ </Button>
+
+ {/* RFQ 밀봉 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSealRfq}
+ className="gap-2"
+ disabled={!selectedRfq || selectedRfq.rfqSealedYn === "Y" || selectedRfq.status !== "RFQ Sent" || isProcessing}
+ >
+ <Lock className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">RFQ 밀봉</span>
+ </Button>
+
+ {/* RFQ 전송 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSendRfq}
+ className="gap-2"
+ disabled={
+ !selectedRfq ||
+ (selectedRfq.status !== "RFQ Vendor Assignned" && selectedRfq.status !== "RFQ Sent") ||
+ isProcessing
+ }
+ >
+ <Send className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">RFQ 전송</span>
+ </Button>
+ </div>
+
+ {/* PR 상세정보 다이얼로그 */}
+ <PrDetailsDialog
+ open={dialogOpen}
+ onOpenChange={setDialogOpen}
+ selectedRfq={selectedRfq}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table.tsx b/lib/procurement-rfqs/table/rfq-table.tsx
new file mode 100644
index 00000000..510f474d
--- /dev/null
+++ b/lib/procurement-rfqs/table/rfq-table.tsx
@@ -0,0 +1,209 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getColumns, EditingCellState } from "./rfq-table-column"
+import { useEffect } from "react"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
+import { ProcurementRfqsView } from "@/db/schema"
+import { getPORfqs } from "../services"
+import { toast } from "sonner"
+import { updateRfqRemark } from "@/lib/procurement-rfqs/services" // 구현 필요
+
+interface RFQListTableProps {
+ data?: Awaited<ReturnType<typeof getPORfqs>>;
+ onSelectRFQ?: (rfq: ProcurementRfqsView | null) => void;
+ // 데이터 새로고침을 위한 콜백 추가
+ onDataRefresh?: () => void;
+ maxHeight?: string | number; // Add this prop
+}
+
+// 보다 유연한 타입 정의
+type LocalDataType = Awaited<ReturnType<typeof getPORfqs>>;
+
+export function RFQListTable({
+ data,
+ onSelectRFQ,
+ onDataRefresh,
+ maxHeight
+}: RFQListTableProps) {
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null)
+ // 인라인 에디팅을 위한 상태 추가
+ const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null)
+ // 로컬 데이터를 관리하기 위한 상태 추가
+ const [localData, setLocalData] = React.useState<LocalDataType>(data || { data: [], pageCount: 0, total: 0 });
+
+ // 데이터가 변경될 때 로컬 데이터도 업데이트
+ useEffect(() => {
+ setLocalData(data || { data: [], pageCount: 0, total: 0 })
+ }, [data])
+
+
+ // 비고 업데이트 함수
+ const updateRemark = async (rfqId: number, remark: string) => {
+ try {
+ // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신)
+ if (localData && localData.data) {
+ // 로컬 데이터에서 해당 행 찾기
+ const rowIndex = localData.data.findIndex(row => row.id === rfqId);
+ if (rowIndex >= 0) {
+ // 불변성을 유지하면서 로컬 데이터 업데이트
+ const newData = [...localData.data];
+ newData[rowIndex] = { ...newData[rowIndex], remark };
+
+ // 전체 데이터 구조 복사하여 업데이트
+ setLocalData({ ...localData, data: newData } as typeof localData);
+ }
+ }
+
+ const result = await updateRfqRemark(rfqId, remark);
+
+ if (result.success) {
+ toast.success("비고가 업데이트되었습니다");
+
+ // 서버 데이터 리프레시 호출
+ if (onDataRefresh) {
+ onDataRefresh();
+ }
+ } else {
+ toast.error(result.message || "업데이트 중 오류가 발생했습니다");
+ }
+ } catch (error) {
+ console.error("비고 업데이트 오류:", error);
+ toast.error("업데이트 중 오류가 발생했습니다");
+ }
+ }
+
+ // 행 액션 처리
+ useEffect(() => {
+ if (rowAction) {
+ // 액션 유형에 따라 처리
+ switch (rowAction.type) {
+ case "select":
+ // 선택된 문서 처리
+ if (onSelectRFQ) {
+ onSelectRFQ(rowAction.row.original)
+ }
+ break;
+ case "update":
+ // 업데이트 처리 로직
+ console.log("Update rfq:", rowAction.row.original)
+ break;
+ case "delete":
+ // 삭제 처리 로직
+ console.log("Delete rfq:", rowAction.row.original)
+ break;
+ }
+
+ // 액션 처리 후 rowAction 초기화
+ setRowAction(null)
+ }
+ }, [rowAction, onSelectRFQ])
+
+ const columns = React.useMemo(
+ () => getColumns({
+ setRowAction,
+ editingCell,
+ setEditingCell,
+ updateRemark
+ }),
+ [setRowAction, editingCell, setEditingCell, updateRemark]
+ )
+
+
+ // Filter fields
+ const filterFields: DataTableFilterField<ProcurementRfqsView>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ProcurementRfqsView>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ No.",
+ type: "text",
+ },
+ {
+ id: "projectCode",
+ label: "프로젝트",
+ type: "text",
+ },
+ {
+ id: "itemCode",
+ label: "자재그룹",
+ type: "text",
+ },
+ {
+ id: "itemName",
+ label: "자재명",
+ type: "text",
+ },
+
+ {
+ id: "rfqSealedYn",
+ label: "RFQ 밀봉여부",
+ type: "text",
+ },
+ {
+ id: "majorItemMaterialCode",
+ label: "자재코드",
+ type: "text",
+ },
+ {
+ id: "rfqSendDate",
+ label: "RFQ 전송일",
+ type: "date",
+ },
+ {
+ id: "dueDate",
+ label: "RFQ 마감일",
+ type: "date",
+ },
+ {
+ id: "createdByUserName",
+ label: "요청자",
+ type: "text",
+ },
+ ]
+
+ // useDataTable 훅으로 react-table 구성 - 로컬 데이터 사용하도록 수정
+ const { table } = useDataTable({
+ data: localData?.data || [],
+ columns,
+ pageCount: localData?.pageCount || 0,
+ rowCount: localData?.total || 0, // 총 레코드 수 추가
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+ })
+
+ return (
+ <div className="w-full overflow-auto">
+ <DataTable table={table} maxHeight={maxHeight}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RFQTableToolbarActions
+ table={table}
+ localData={localData}
+ setLocalData={setLocalData}
+ onSuccess={onDataRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/validations.ts b/lib/procurement-rfqs/validations.ts
new file mode 100644
index 00000000..5059755f
--- /dev/null
+++ b/lib/procurement-rfqs/validations.ts
@@ -0,0 +1,61 @@
+import { createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,parseAsBoolean
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { ProcurementRfqsView, ProcurementVendorQuotations } from "@/db/schema";
+
+
+// =======================
+// 1) SearchParams (목록 필터링/정렬)
+// =======================
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<ProcurementRfqsView>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
+export type GetPORfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+
+
+export const searchParamsVendorRfqCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<ProcurementVendorQuotations>().withDefault([
+ { id: "updatedAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 기본 필터 (RFQFilterBox) - 새로운 필드 추가
+ basicFilters: getFiltersStateParser().withDefault([]),
+ basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ search: parseAsString.withDefault(""),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+});
+
+export type GetQuotationsSchema = Awaited<ReturnType<typeof searchParamsVendorRfqCache.parse>>; \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx b/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx
new file mode 100644
index 00000000..69ba0363
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx
@@ -0,0 +1,522 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X,
+ User,
+ Building
+} from "lucide-react"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime, formatFileSize } from "@/lib/utils"
+import { useSession } from "next-auth/react"
+import { fetchBuyerVendorComments } from "../services"
+
+// 타입 정의
+interface Comment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string;
+ isVendorComment: boolean | null; // null 허용으로 변경
+ createdAt: Date;
+ updatedAt: Date;
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: Attachment[];
+ isRead: boolean | null // null 허용으로 변경
+}
+
+interface Attachment {
+ id: number;
+ fileName: string;
+ fileSize: number;
+ fileType: string | null; // null 허용으로 변경
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface BuyerCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ quotation: {
+ id: number;
+ rfqId: number;
+ vendorId: number;
+ quotationCode: string;
+ rfq?: {
+ rfqCode: string;
+ };
+ } | null;
+ onSuccess?: () => void;
+}
+
+
+
+// 벤더 코멘트 전송 함수
+export function sendVendorCommentClient(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'true'); // 벤더가 보내는 메시지이므로 true
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성 (벤더 API 경로)
+ const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ return fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ })
+ .then(response => {
+ if (!response.ok) {
+ return response.text().then(text => {
+ throw new Error(`API 요청 실패: ${response.status} ${text}`);
+ });
+ }
+ return response.json();
+ })
+ .then(result => {
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+ return result.data.comment;
+ });
+}
+
+
+export function BuyerCommunicationDrawer({
+ open,
+ onOpenChange,
+ quotation,
+ onSuccess
+}: BuyerCommunicationDrawerProps) {
+ // 세션 정보
+ const { data: session } = useSession();
+
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && quotation) {
+ loadComments();
+ }
+ }, [open, quotation]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 코멘트 로드 함수
+ const loadComments = async () => {
+ if (!quotation) return;
+
+ try {
+ setIsLoading(true);
+
+ // API를 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽음 상태 처리는 API 측에서 처리되는 것으로 가정
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!quotation) return;
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendVendorCommentClient({
+ rfqId: quotation.rfqId,
+ vendorId: quotation.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: Attachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType.startsWith("image/");
+ const isPdf = selectedAttachment.fileType.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType)}
+ {selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType)}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!quotation) {
+ return null;
+ }
+
+ // 구매자 정보 (실제로는 API에서 가져와야 함)
+ const buyerName = "구매 담당자";
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[85vh]">
+ <DrawerHeader className="border-b">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{buyerName}</span>
+ <Badge variant="outline" className="ml-2">구매자</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {quotation.rfq?.rfqCode || "N/A"} • 견적서: {quotation.quotationCode}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="p-0 flex flex-col h-[60vh]">
+ {/* 메시지 목록 */}
+ <ScrollArea className="flex-1 p-4">
+ {isLoading ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-end' : 'justify-start'}`}
+ >
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ <User className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${comment.isVendorComment
+ ? 'bg-primary text-primary-foreground'
+ : 'bg-muted'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? (
+ session?.user?.name || "벤더"
+ ) : (
+ comment.userName || buyerName
+ )}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${comment.isVendorComment
+ ? 'border-t border-t-primary-foreground/20'
+ : 'border-t border-t-border/30'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType)}
+ <span className="flex-1 truncate">{attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ <Building className="h-4 w-4" />
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t">
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx
new file mode 100644
index 00000000..963c2f85
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/quotation-editor.tsx
@@ -0,0 +1,953 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useMemo } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { format } from "date-fns"
+import { toast } from "sonner"
+import { MessageSquare, Paperclip } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { DatePicker } from "@/components/ui/date-picker"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+
+import { formatCurrency, formatDate } from "@/lib/utils"
+import { QuotationItemEditor } from "./quotation-item-editor"
+import {
+ submitVendorQuotation,
+ updateVendorQuotation,
+ fetchCurrencies,
+ fetchPaymentTerms,
+ fetchIncoterms,
+ fetchBuyerVendorComments,
+ Comment
+} from "../services"
+import { BuyerCommunicationDrawer } from "./buyer-communication-drawer"
+
+// 견적서 폼 스키마
+const quotationFormSchema = z.object({
+ quotationVersion: z.number().min(1),
+ // 필수값 표시됨
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ // 필수값 표시됨
+ validUntil: z.date({
+ required_error: "견적 유효기간을 선택해주세요",
+ invalid_type_error: "유효한 날짜를 선택해주세요",
+ }),
+ // 필수값 표시됨
+ estimatedDeliveryDate: z.date({
+ required_error: "예상 납품일을 선택해주세요",
+ invalid_type_error: "유효한 날짜를 선택해주세요",
+ }),
+ // 필수값 표시됨
+ paymentTermsCode: z.string({
+ required_error: "지불 조건을 선택해주세요",
+ }).min(1, "지불 조건을 선택해주세요"),
+ // 필수값 표시됨
+ incotermsCode: z.string({
+ required_error: "인코텀즈를 선택해주세요",
+ }).min(1, "인코텀즈를 선택해주세요"),
+ // 필수값 아님
+ incotermsDetail: z.string().optional(),
+ // 필수값 아님
+ remark: z.string().optional(),
+})
+
+type QuotationFormValues = z.infer<typeof quotationFormSchema>
+
+// 데이터 타입 정의
+interface Currency {
+ code: string
+ name: string
+}
+
+interface PaymentTerm {
+ code: string
+ description: string
+}
+
+interface Incoterm {
+ code: string
+ description: string
+}
+
+// 이 컴포넌트에 전달되는 견적서 데이터 타입
+interface VendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ quotationVersion: number | null
+ totalItemsCount: number | null
+ subTotal: string| null
+ taxTotal: string| null
+ discountTotal: string| null
+ totalPrice: string| null
+ currency: string| null
+ validUntil: Date | null
+ estimatedDeliveryDate: Date | null
+ paymentTermsCode: string | null
+ incotermsCode: string | null
+ incotermsDetail: string | 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
+ // 기타 필요한 정보
+ }
+ vendor: {
+ id: number
+ vendorName: string
+ vendorCode: string| null
+ // 기타 필요한 정보
+ }
+ items: QuotationItem[]
+}
+
+// 견적 아이템 타입
+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
+ createdAt: Date
+ updatedAt: Date
+ prItem?: {
+ id: number
+ materialCode: string | null
+ materialDescription: string | null
+ // 기타 필요한 정보
+ }
+}
+
+// 견적서 편집 컴포넌트 프롭스
+interface VendorQuotationEditorProps {
+ quotation: VendorQuotation
+}
+
+export default function VendorQuotationEditor({ quotation }: VendorQuotationEditorProps) {
+
+
+ const [activeTab, setActiveTab] = useState("items")
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [items, setItems] = useState<QuotationItem[]>(quotation.items || [])
+
+ // 서버에서 가져온 데이터 상태
+ const [currencies, setCurrencies] = useState<Currency[]>([])
+ const [paymentTerms, setPaymentTerms] = useState<PaymentTerm[]>([])
+ const [incoterms, setIncoterms] = useState<Incoterm[]>([])
+
+ // 데이터 로딩 상태
+ const [loadingCurrencies, setLoadingCurrencies] = useState(true)
+ const [loadingPaymentTerms, setLoadingPaymentTerms] = useState(true)
+ const [loadingIncoterms, setLoadingIncoterms] = useState(true)
+
+ // 커뮤니케이션 드로어 상태
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [unreadCount, setUnreadCount] = useState(0);
+ const [loadingComments, setLoadingComments] = useState(false);
+
+ // 컴포넌트 마운트 시 메시지 미리 로드
+ useEffect(() => {
+ if (quotation) {
+ loadCommunicationData();
+ }
+ }, [quotation]);
+
+ // 메시지 데이터 로드 함수
+ const loadCommunicationData = async () => {
+ try {
+ setLoadingComments(true);
+ const commentsData = await fetchBuyerVendorComments(quotation.rfqId, quotation.vendorId);
+ setComments(commentsData);
+
+ // 읽지 않은 메시지 수 계산
+ const unread = commentsData.filter(
+ comment => !comment.isVendorComment && !comment.isRead
+ ).length;
+ setUnreadCount(unread);
+ } catch (error) {
+ console.error("메시지 데이터 로드 오류:", error);
+ } finally {
+ setLoadingComments(false);
+ }
+ };
+
+ // 커뮤니케이션 드로어가 닫힐 때 데이터 새로고침
+ const handleCommunicationDrawerChange = (open: boolean) => {
+ setCommunicationDrawerOpen(open);
+ if (!open) {
+ loadCommunicationData(); // 드로어가 닫힐 때 데이터 새로고침
+ }
+ };
+
+ // 버튼 비활성화
+ const isBeforeDueDate = () => {
+ if (!quotation.rfq.dueDate) {
+ // dueDate가 null인 경우 기본적으로 수정 불가능하도록 설정 (false 반환)
+ return false;
+ }
+
+ const now = new Date();
+ const dueDate = new Date(quotation.rfq.dueDate);
+ return now < dueDate;
+ };
+ // 수정된 isDisabled 조건
+ const isDisabled = (quotation.status === "Accepted") ||
+ ((quotation.status === "Submitted" || quotation.status === "Revised") &&
+ !isBeforeDueDate());
+
+
+ // 견적서 총합 계산
+ const totals = useMemo(() => {
+ const subTotal = items.reduce((sum, item) => sum + Number(item.totalPrice), 0)
+ const taxTotal = items.reduce((sum, item) => sum + (Number(item.taxAmount) || 0), 0)
+ const discountTotal = items.reduce((sum, item) => sum + (Number(item.discountAmount) || 0), 0)
+ const totalPrice = subTotal + taxTotal - discountTotal
+
+ return {
+ subTotal,
+ taxTotal,
+ discountTotal,
+ totalPrice
+ }
+ }, [items])
+
+ // 폼 설정
+ const form = useForm<QuotationFormValues>({
+ resolver: zodResolver(quotationFormSchema),
+ defaultValues: {
+ quotationVersion: quotation.quotationVersion || 0,
+ currency: quotation.currency || "KRW",
+ validUntil: quotation.validUntil || undefined,
+ estimatedDeliveryDate: quotation.estimatedDeliveryDate || undefined,
+ paymentTermsCode: quotation.paymentTermsCode || "",
+ incotermsCode: quotation.incotermsCode || "",
+ incotermsDetail: quotation.incotermsDetail || "",
+ remark: quotation.remark || "",
+ },
+ mode: "onChange", // 실시간 검증 활성화
+ })
+
+ // 마운트 시 데이터 로드
+ useEffect(() => {
+ // 통화 데이터 로드
+ const loadCurrencies = async () => {
+ try {
+ setLoadingCurrencies(true)
+ const result = await fetchCurrencies()
+ if (result.success) {
+ setCurrencies(result.data)
+ } else {
+ toast.error(result.message || "통화 데이터 로드 실패")
+ }
+ } catch (error) {
+ console.error("통화 데이터 로드 오류:", error)
+ toast.error("통화 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setLoadingCurrencies(false)
+ }
+ }
+
+ // 지불 조건 데이터 로드
+ const loadPaymentTerms = async () => {
+ try {
+ setLoadingPaymentTerms(true)
+ const result = await fetchPaymentTerms()
+ if (result.success) {
+ setPaymentTerms(result.data)
+ } else {
+ toast.error(result.message || "지불 조건 데이터 로드 실패")
+ }
+ } catch (error) {
+ console.error("지불 조건 데이터 로드 오류:", error)
+ toast.error("지불 조건 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setLoadingPaymentTerms(false)
+ }
+ }
+
+ // 인코텀즈 데이터 로드
+ const loadIncoterms = async () => {
+ try {
+ setLoadingIncoterms(true)
+ const result = await fetchIncoterms()
+ if (result.success) {
+ setIncoterms(result.data)
+ } else {
+ toast.error(result.message || "인코텀즈 데이터 로드 실패")
+ }
+ } catch (error) {
+ console.error("인코텀즈 데이터 로드 오류:", error)
+ toast.error("인코텀즈 데이터를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setLoadingIncoterms(false)
+ }
+ }
+
+ // 함수 호출
+ loadCurrencies()
+ loadPaymentTerms()
+ loadIncoterms()
+ }, [])
+
+ // 견적서 저장
+ const handleSave = async () => {
+ try {
+ setIsSaving(true)
+
+ // 기본 검증 (통화는 필수)
+ const validationResult = await form.trigger(['currency']);
+ if (!validationResult) {
+ toast.warning("통화는 필수 항목입니다");
+ return;
+ }
+
+ const values = form.getValues()
+
+ const result = await updateVendorQuotation({
+ id: quotation.id,
+ ...values,
+ subTotal: totals.subTotal.toString(),
+ taxTotal: totals.taxTotal.toString(),
+ discountTotal: totals.discountTotal.toString(),
+ totalPrice: totals.totalPrice.toString(),
+ totalItemsCount: items.length,
+ })
+
+ if (result.success) {
+ toast.success("견적서가 저장되었습니다")
+
+ // 견적서 제출 준비 상태 점검
+ const formValid = await form.trigger();
+ const itemsValid = !items.some(item => item.unitPrice <= 0 || !item.deliveryDate);
+ const alternativeItemsValid = !items.some(item =>
+ item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
+ );
+
+ if (formValid && itemsValid && alternativeItemsValid) {
+ toast.info("모든 필수 정보가 입력되었습니다. '견적서 제출' 버튼을 클릭하여 제출하세요.");
+ } else {
+ const missingFields = [];
+ if (!formValid) missingFields.push("견적서 기본 정보");
+ if (!itemsValid) missingFields.push("견적 항목의 단가/납품일");
+ if (!alternativeItemsValid) missingFields.push("대체품 정보");
+
+ toast.info(`제출하기 전에 다음 정보를 입력해주세요: ${missingFields.join(', ')}`);
+ }
+ } else {
+ toast.error(result.message || "견적서 저장 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("견적서 저장 오류:", error)
+ toast.error("견적서 저장 중 오류가 발생했습니다")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // 견적서 제출
+ const handleSubmit = async () => {
+ try {
+ setIsSubmitting(true)
+
+ // 1. 폼 스키마 검증 (기본 정보)
+ const formValid = await form.trigger();
+ if (!formValid) {
+ const formState = form.getFieldState("validUntil");
+ const estimatedDeliveryState = form.getFieldState("estimatedDeliveryDate");
+ const paymentTermsState = form.getFieldState("paymentTermsCode");
+ const incotermsState = form.getFieldState("incotermsCode");
+
+ // 주요 필드별 오류 메시지 표시
+ if (!form.getValues("validUntil")) {
+ toast.error("견적 유효기간을 선택해주세요");
+ } else if (!form.getValues("estimatedDeliveryDate")) {
+ toast.error("예상 납품일을 선택해주세요");
+ } else if (!form.getValues("paymentTermsCode")) {
+ toast.error("지불 조건을 선택해주세요");
+ } else if (!form.getValues("incotermsCode")) {
+ toast.error("인코텀즈를 선택해주세요");
+ } else {
+ toast.error("견적서 기본 정보를 모두 입력해주세요");
+ }
+
+ // 견적 정보 탭으로 이동
+ setActiveTab("details");
+ return;
+ }
+
+ // 2. 견적 항목 검증
+ const emptyItems = items.filter(item =>
+ item.unitPrice <= 0 || !item.deliveryDate
+ );
+
+ if (emptyItems.length > 0) {
+ toast.error(`${emptyItems.length}개 항목의 단가와 납품일을 입력해주세요`);
+ setActiveTab("items");
+ return;
+ }
+
+ // 3. 대체품 정보 검증
+ const invalidAlternativeItems = items.filter(item =>
+ item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
+ );
+
+ if (invalidAlternativeItems.length > 0) {
+ toast.error(`${invalidAlternativeItems.length}개의 대체품 항목에 정보를 모두 입력해주세요`);
+ setActiveTab("items");
+ return;
+ }
+
+ // 모든 검증 통과 - 제출 진행
+ const values = form.getValues();
+
+ const result = await submitVendorQuotation({
+ id: quotation.id,
+ ...values,
+ subTotal: totals.subTotal.toString(),
+ taxTotal: totals.taxTotal.toString(),
+ discountTotal: totals.discountTotal.toString(),
+ totalPrice: totals.totalPrice.toString(),
+ totalItemsCount: items.length,
+ });
+
+ if (result.success && isBeforeDueDate()) {
+ toast.success("견적서가 제출되었습니다. 마감일 전까지 수정 가능합니다.");
+
+ // 페이지 새로고침
+ window.location.reload();
+ } else {
+ toast.error(result.message || "견적서 제출 중 오류가 발생했습니다");
+ }
+ } catch (error) {
+ console.error("견적서 제출 오류:", error);
+ toast.error("견적서 제출 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ const isSubmitReady = () => {
+ // 폼 유효성
+ const formValid = !Object.keys(form.formState.errors).length;
+
+ // 항목 유효성
+ const itemsValid = !items.some(item =>
+ item.unitPrice <= 0 || !item.deliveryDate
+ );
+
+ // 대체품 유효성
+ const alternativeItemsValid = !items.some(item =>
+ item.isAlternative && (!item.vendorMaterialDescription || !item.remark)
+ );
+
+ // 유효하지 않은 항목 또는 대체품이 있으면 제출 불가
+ return formValid && itemsValid && alternativeItemsValid;
+ }
+
+ // 아이템 업데이트 핸들러
+ const handleItemsUpdate = (updatedItems: QuotationItem[]) => {
+ setItems(updatedItems)
+ }
+
+ // 상태에 따른 배지 색상
+ const getStatusBadge = (status: string) => {
+ switch (status) {
+ case "Draft":
+ return <Badge variant="outline">초안</Badge>
+ case "Submitted":
+ return <Badge variant="default">제출됨</Badge>
+ case "Revised":
+ return <Badge variant="secondary">수정됨</Badge>
+ case "Rejected":
+ return <Badge variant="destructive">반려됨</Badge>
+ case "Accepted":
+ return <Badge variant="default">승인됨</Badge>
+ default:
+ return <Badge>{status}</Badge>
+ }
+ }
+
+ // 셀렉트 로딩 상태 표시 컴포넌트
+ const SelectSkeleton = () => (
+ <div className="flex flex-col gap-2">
+ <Skeleton className="h-4 w-[40%]" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ )
+
+ return (
+ <div className="space-y-6">
+ <div className="flex justify-between items-start">
+ <div>
+ <h1 className="text-2xl font-bold tracking-tight">견적서 작성</h1>
+ <p className="text-muted-foreground">
+ RFQ 번호: {quotation.rfq.rfqCode} | 견적서 번호: {quotation.quotationCode}
+ </p>
+ {quotation.rfq.dueDate ? (
+ <p className={`text-sm ${isBeforeDueDate() ? 'text-green-600' : 'text-red-600'}`}>
+ 마감일: {formatDate(new Date(quotation.rfq.dueDate))}
+ {isBeforeDueDate()
+ ? ' (마감 전: 수정 가능)'
+ : ' (마감 됨: 수정 불가)'}
+ </p>
+ ) : (
+ <p className="text-sm text-amber-600">
+ 마감일이 설정되지 않았습니다
+ </p>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {getStatusBadge(quotation.status)}
+ {quotation.status === "Rejected" && (
+ <div className="text-sm text-destructive">
+ <span className="font-medium">반려 사유:</span> {quotation.rejectionReason || "사유 없음"}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList>
+ <TabsTrigger value="items">견적 항목</TabsTrigger>
+ <TabsTrigger value="details">견적 정보</TabsTrigger>
+ <TabsTrigger value="communication">커뮤니케이션</TabsTrigger>
+ </TabsList>
+
+ {/* 견적 항목 탭 */}
+ <TabsContent value="items" className="p-0 pt-4">
+ <Card>
+ <CardHeader>
+ <CardTitle>견적 항목 정보</CardTitle>
+ <CardDescription>
+ 각 항목에 대한 가격, 납품일 등을 입력해주세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <QuotationItemEditor
+ items={items}
+ onItemsChange={handleItemsUpdate}
+ disabled={isDisabled}
+ currency={form.watch("currency")}
+ />
+ </CardContent>
+ <CardFooter className="flex justify-between border-t p-4">
+ <div className="space-y-1">
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">소계:</span> {formatCurrency(totals.subTotal, quotation.currency)}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">세액:</span> {formatCurrency(totals.taxTotal, quotation.currency)}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">할인액:</span> {formatCurrency(totals.discountTotal, quotation.currency)}
+ </div>
+ <div className="text-base font-bold">
+ <span>총액:</span> {formatCurrency(totals.totalPrice, quotation.currency)}
+ </div>
+ </div>
+ <div className="flex space-x-2">
+ <Button
+ variant="outline"
+ onClick={handleSave}
+ disabled={isDisabled || isSaving}
+ >
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isDisabled || isSubmitting || !isSubmitReady()}
+ >
+ {isSubmitting ? "제출 중..." : "견적서 제출"}
+ </Button>
+ </div>
+ </CardFooter>
+ </Card>
+ </TabsContent>
+
+ {/* 견적 정보 탭 */}
+ <TabsContent value="details" className="p-0 pt-4">
+ <Form {...form}>
+ <form className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>견적서 기본 정보</CardTitle>
+ <CardDescription>
+ 견적서의 일반 정보를 입력해주세요
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ {/* 통화 필드 */}
+ {loadingCurrencies ? (
+ <SelectSkeleton />
+ ) : (
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 통화
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isDisabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.code} ({currency.name})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ <FormField
+ control={form.control}
+ name="validUntil"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 견적 유효기간
+ <span className="text-destructive ml-1">*</span> {/* 필수값 표시 */}
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onSelect={field.onChange}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="estimatedDeliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 예상 납품일
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onSelect={field.onChange}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 지불 조건 필드 */}
+ {loadingPaymentTerms ? (
+ <SelectSkeleton />
+ ) : (
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 지불 조건
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={isDisabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 인코텀즈 필드 */}
+ {loadingIncoterms ? (
+ <SelectSkeleton />
+ ) : (
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 인코텀즈
+ <span className="text-destructive ml-1">*</span>
+ </FormLabel>
+ <Select
+ value={field.value || ""}
+ onValueChange={field.onChange}
+ disabled={isDisabled}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.code} ({term.description})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center">
+ 인코텀즈 상세
+ <span className="text-destructive ml-1"></span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="인코텀즈 상세 정보 입력"
+ {...field}
+ value={field.value || ""}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem className="col-span-2">
+ <FormLabel className="flex items-center">
+ 비고
+ <span className="text-destructive ml-1"></span>
+ </FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 정보나 특이사항을 입력해주세요"
+ className="resize-none min-h-[100px]"
+ {...field}
+ value={field.value || ""}
+ disabled={isDisabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ <CardFooter className="flex justify-end">
+ <Button
+ variant="outline"
+ onClick={handleSave}
+ disabled={isDisabled || isSaving}
+ >
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ </CardFooter>
+ </Card>
+ </form>
+ </Form>
+ </TabsContent>
+
+ {/* 커뮤니케이션 탭 */}
+ <TabsContent value="communication" className="p-0 pt-4">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ 커뮤니케이션
+ {unreadCount > 0 && (
+ <Badge variant="destructive" className="ml-2">
+ 새 메시지 {unreadCount}
+ </Badge>
+ )}
+ </CardTitle>
+ <CardDescription>
+ 구매자와의 메시지 및 첨부파일
+ </CardDescription>
+ </div>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ variant="outline"
+ size="sm"
+ >
+ <MessageSquare className="h-4 w-4 mr-2" />
+ {unreadCount > 0 ? "새 메시지 확인" : "메시지 보내기"}
+ </Button>
+ </CardHeader>
+ <CardContent>
+ {loadingComments ? (
+ <div className="flex items-center justify-center p-8">
+ <div className="text-center">
+ <Skeleton className="h-4 w-32 mx-auto mb-2" />
+ <Skeleton className="h-4 w-48 mx-auto" />
+ </div>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="min-h-[200px] flex flex-col items-center justify-center text-center p-8">
+ <div className="max-w-md">
+ <div className="mx-auto bg-primary/10 rounded-full w-12 h-12 flex items-center justify-center mb-4">
+ <MessageSquare className="h-6 w-6 text-primary" />
+ </div>
+ <h3 className="text-lg font-medium mb-2">아직 메시지가 없습니다</h3>
+ <p className="text-muted-foreground mb-4">
+ 견적서에 대한 질문이나 의견이 있으신가요? 구매자와 메시지를 주고받으세요.
+ </p>
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="mx-auto"
+ >
+ 메시지 보내기
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 최근 메시지 3개 미리보기 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">최근 메시지</h3>
+ <ScrollArea className="h-[250px] rounded-md border p-4">
+ {comments.slice(-3).map(comment => (
+ <div
+ key={comment.id}
+ className={`p-3 mb-3 rounded-lg ${!comment.isVendorComment && !comment.isRead
+ ? 'bg-primary/10 border-l-4 border-primary'
+ : 'bg-muted/50'
+ }`}
+ >
+ <div className="flex justify-between items-center mb-1">
+ <span className="text-sm font-medium">
+ {comment.isVendorComment
+ ? '나'
+ : comment.userName || '구매 담당자'}
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {new Date(comment.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ <p className="text-sm line-clamp-2">{comment.content}</p>
+ {comment.attachments.length > 0 && (
+ <div className="mt-1 text-xs text-muted-foreground">
+ <Paperclip className="h-3 w-3 inline mr-1" />
+ 첨부파일 {comment.attachments.length}개
+ </div>
+ )}
+ </div>
+ ))}
+ </ScrollArea>
+ </div>
+
+ <div className="flex justify-center">
+ <Button
+ onClick={() => setCommunicationDrawerOpen(true)}
+ className="w-full"
+ >
+ 전체 메시지 보기 ({comments.length}개)
+ </Button>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 커뮤니케이션 드로어 */}
+ <BuyerCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={handleCommunicationDrawerChange}
+ quotation={quotation}
+ onSuccess={loadCommunicationData}
+ />
+ </TabsContent>
+ </Tabs>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx
new file mode 100644
index 00000000..e11864dc
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx
@@ -0,0 +1,664 @@
+"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/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
new file mode 100644
index 00000000..9eecc72f
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -0,0 +1,239 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, FileText, Pencil, Edit, Trash2 } from "lucide-react"
+import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import Link from "next/link"
+import { ProcurementVendorQuotations } from "@/db/schema"
+import { useRouter } from "next/navigation"
+
+// 상태에 따른 배지 컴포넌트
+function StatusBadge({ status }: { status: string }) {
+ switch (status) {
+ case "Draft":
+ return <Badge variant="outline">초안</Badge>
+ case "Submitted":
+ return <Badge variant="default">제출됨</Badge>
+ case "Revised":
+ return <Badge variant="secondary">수정됨</Badge>
+ case "Rejected":
+ return <Badge variant="destructive">반려됨</Badge>
+ case "Accepted":
+ return <Badge variant="default">승인됨</Badge>
+ default:
+ return <Badge>{status}</Badge>
+ }
+}
+
+interface QuotationWithRfqCode extends ProcurementVendorQuotations {
+ rfqCode?: string;
+ rfq?: {
+ rfqCode?: string;
+ dueDate?: Date | string | null;
+
+ } | null;
+}
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<QuotationWithRfqCode> | null>
+ >
+ router: NextRouter
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({
+ setRowAction,
+ router,
+}: GetColumnsProps): ColumnDef<QuotationWithRfqCode>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<QuotationWithRfqCode> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들
+ // ----------------------------------------------------------------
+
+ // 견적서 액션 컬럼 (아이콘 버튼으로 변경)
+ const quotationActionColumn: ColumnDef<QuotationWithRfqCode> = {
+ id: "actions",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const id = row.original.id
+ const code = row.getValue("quotationCode") as string
+ const tooltipText = `${code} 작성하기`
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => router.push(`/partners/rfq-all/${id}`)}
+ className="h-8 w-8"
+ >
+ <Edit className="h-4 w-4" />
+ <span className="sr-only">견적서 작성</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+ },
+ size: 50, // 아이콘으로 변경했으므로 크기 줄임
+ }
+
+ // RFQ 번호 컬럼
+ const rfqCodeColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "quotationCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 번호" />
+ ),
+ cell: ({ row }) => row.original.quotationCode || "-",
+ size: 150,
+ }
+
+ // RFQ 버전 컬럼
+ const quotationVersionColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "quotationVersion",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 버전" />
+ ),
+ cell: ({ row }) => row.original.quotationVersion || "-",
+ size: 100,
+ }
+
+ const dueDateColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 마감일" />
+ ),
+ cell: ({ row }) => {
+ // 타입 단언 사용
+ const rfq = row.original.rfq as any;
+ const date = rfq?.dueDate as string | null;
+ return date ? formatDateTime(new Date(date)) : "-";
+ },
+ size: 100,
+ }
+
+ // 상태 컬럼
+ const statusColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => <StatusBadge status={row.getValue("status") as string} />,
+ size: 100,
+ }
+
+ // 총액 컬럼
+ const totalPriceColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="총액" />
+ ),
+ cell: ({ row }) => {
+ const price = parseFloat(row.getValue("totalPrice") as string || "0")
+ const currency = row.original.currency
+
+ return formatCurrency(price, currency)
+ },
+ size: 120,
+ }
+
+ // 제출일 컬럼
+ const submittedAtColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "submittedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="제출일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("submittedAt") as string | null
+ return date ? formatDate(new Date(date)) : "-"
+ },
+ size: 120,
+ }
+
+ // 유효기간 컬럼
+ const validUntilColumn: ColumnDef<QuotationWithRfqCode> = {
+ accessorKey: "validUntil",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효기간" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("validUntil") as string | null
+ return date ? formatDate(new Date(date)) : "-"
+ },
+ size: 120,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ rfqCodeColumn,
+ quotationVersionColumn,
+ dueDateColumn,
+ statusColumn,
+ totalPriceColumn,
+ submittedAtColumn,
+ validUntilColumn,
+ quotationActionColumn // 이름을 변경하고 마지막에 배치
+ ]
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx
new file mode 100644
index 00000000..92bda337
--- /dev/null
+++ b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx
@@ -0,0 +1,145 @@
+// lib/vendor-quotations/vendor-quotations-table.tsx
+"use client"
+
+import * as React from "react"
+import { type DataTableAdvancedFilterField, type DataTableFilterField, type DataTableRowAction } from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { Button } from "@/components/ui/button"
+import { ProcurementVendorQuotations } from "@/db/schema"
+import { useRouter } from "next/navigation"
+import { getColumns } from "./vendor-quotations-table-columns"
+
+interface QuotationWithRfqCode extends ProcurementVendorQuotations {
+ rfqCode?: string;
+ rfq?: {
+ rfqCode?: string;
+ } | null;
+}
+
+interface VendorQuotationsTableProps {
+ promises: Promise<[{ data: ProcurementVendorQuotations[], pageCount: number }]>;
+}
+
+export function VendorQuotationsTable({ promises }: VendorQuotationsTableProps) {
+ const [{ data, pageCount }] = React.use(promises);
+ const router = useRouter();
+
+ console.log(data ,"data")
+
+ // 선택된 행 액션 상태
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<QuotationWithRfqCode> | null>(null);
+
+ // 테이블 컬럼 정의
+ const columns = React.useMemo(() => getColumns({
+ setRowAction,
+ router,
+ }), [setRowAction, router]);
+
+ // 상태별 견적서 수 계산
+ const statusCounts = React.useMemo(() => {
+ return {
+ Draft: data.filter(q => q.status === "Draft").length,
+ Submitted: data.filter(q => q.status === "Submitted").length,
+ Revised: data.filter(q => q.status === "Revised").length,
+ Rejected: data.filter(q => q.status === "Rejected").length,
+ Accepted: data.filter(q => q.status === "Accepted").length,
+ };
+ }, [data]);
+
+ // 필터 필드
+ const filterFields: DataTableFilterField<QuotationWithRfqCode>[] = [
+ {
+ id: "status",
+ label: "상태",
+ options: [
+ { label: "초안", value: "Draft", count: statusCounts.Draft },
+ { label: "제출됨", value: "Submitted", count: statusCounts.Submitted },
+ { label: "수정됨", value: "Revised", count: statusCounts.Revised },
+ { label: "반려됨", value: "Rejected", count: statusCounts.Rejected },
+ { label: "승인됨", value: "Accepted", count: statusCounts.Accepted },
+ ]
+ },
+ {
+ id: "quotationCode",
+ label: "견적서 번호",
+ placeholder: "견적서 번호 검색...",
+ },
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ placeholder: "RFQ 번호 검색...",
+ }
+ ];
+
+ // 고급 필터 필드
+ const advancedFilterFields: DataTableAdvancedFilterField<QuotationWithRfqCode>[] = [
+ {
+ id: "quotationCode",
+ label: "견적서 번호",
+ type: "text",
+ },
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "상태",
+ type: "multi-select",
+ options: [
+ { label: "초안", value: "Draft" },
+ { label: "제출됨", value: "Submitted" },
+ { label: "수정됨", value: "Revised" },
+ { label: "반려됨", value: "Rejected" },
+ { label: "승인됨", value: "Accepted" },
+ ],
+ },
+ {
+ id: "validUntil",
+ label: "유효기간",
+ type: "date",
+ },
+ {
+ id: "submittedAt",
+ label: "제출일",
+ type: "date",
+ },
+ ];
+
+ // useDataTable 훅 사용
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ return (
+ <div style={{ maxWidth: '100vw' }}>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+
+ </div>
+ );
+} \ No newline at end of file