diff options
| -rw-r--r-- | lib/procurement-rfqs/repository.ts | 50 | ||||
| -rw-r--r-- | lib/procurement-rfqs/services.ts | 2055 | ||||
| -rw-r--r-- | lib/procurement-rfqs/table/pr-item-dialog.tsx | 258 | ||||
| -rw-r--r-- | lib/procurement-rfqs/table/rfq-table-column.tsx | 373 | ||||
| -rw-r--r-- | lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx | 279 | ||||
| -rw-r--r-- | lib/procurement-rfqs/table/rfq-table.tsx | 209 | ||||
| -rw-r--r-- | lib/procurement-rfqs/validations.ts | 61 | ||||
| -rw-r--r-- | lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx | 522 | ||||
| -rw-r--r-- | lib/procurement-rfqs/vendor-response/quotation-editor.tsx | 953 | ||||
| -rw-r--r-- | lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx | 664 | ||||
| -rw-r--r-- | lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx | 239 | ||||
| -rw-r--r-- | lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx | 145 |
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 |
