diff options
Diffstat (limited to 'lib/procurement-rfqs')
20 files changed, 0 insertions, 10003 deletions
diff --git a/lib/procurement-rfqs/repository.ts b/lib/procurement-rfqs/repository.ts deleted file mode 100644 index eb48bc42..00000000 --- a/lib/procurement-rfqs/repository.ts +++ /dev/null @@ -1,50 +0,0 @@ -// 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 deleted file mode 100644 index 9cca4c73..00000000 --- a/lib/procurement-rfqs/services.ts +++ /dev/null @@ -1,2050 +0,0 @@ -"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, - itemCode: true, - itemName: true, - }, - with: { - project: { - columns: { - id: true, - code: true, - name: 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, "KR") : 'N/A', - deliveryDate: detail.deliveryDate ? formatDate(detail.deliveryDate, "KR") : '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]); - - - // 페이지 수 계산 - 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, - itemCode: randomItem.itemCode || `ITEM-${Math.floor(Math.random() * 1000)}`, // itemId 대신 itemCode 사용 - itemName: randomItem.itemName || `임의 아이템 ${Math.floor(Math.random() * 100)}`, // itemName 추가 - 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/detail-table/add-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx deleted file mode 100644 index 79524f58..00000000 --- a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx +++ /dev/null @@ -1,512 +0,0 @@ -"use client" - -import * as React from "react" -import { useState } from "react" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { toast } from "sonner" -import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react" - -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { ProcurementRfqsView } from "@/db/schema" -import { addVendorToRfq } from "@/lib/procurement-rfqs/services" -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { cn } from "@/lib/utils" -import { ScrollArea } from "@/components/ui/scroll-area" - -// 필수 필드를 위한 커스텀 레이블 컴포넌트 -const RequiredLabel = ({ children }: { children: React.ReactNode }) => ( - <FormLabel> - {children} <span className="text-red-500">*</span> - </FormLabel> -); - -// 폼 유효성 검증 스키마 -const vendorFormSchema = z.object({ - vendorId: z.string().min(1, "벤더를 선택해주세요"), - currency: z.string().min(1, "통화를 선택해주세요"), - paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), - incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), - incotermsDetail: z.string().optional(), - deliveryDate: z.string().optional(), - taxCode: z.string().optional(), - placeOfShipping: z.string().optional(), - placeOfDestination: z.string().optional(), - materialPriceRelatedYn: z.boolean().default(false), -}) - -type VendorFormValues = z.infer<typeof vendorFormSchema> - -interface AddVendorDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedRfq: ProcurementRfqsView | null - // 벤더 및 기타 옵션 데이터를 prop으로 받음 - vendors?: { id: number; vendorName: string; vendorCode: string }[] - currencies?: { code: string; name: string }[] - paymentTerms?: { code: string; description: string }[] - incoterms?: { code: string; description: string }[] - onSuccess?: () => void - existingVendorIds?: number[] - -} - -export function AddVendorDialog({ - open, - onOpenChange, - selectedRfq, - vendors = [], - currencies = [], - paymentTerms = [], - incoterms = [], - onSuccess, - existingVendorIds = [], // 기본값 빈 배열 -}: AddVendorDialogProps) { - - - const availableVendors = React.useMemo(() => { - return vendors.filter(vendor => !existingVendorIds.includes(vendor.id)); - }, [vendors, existingVendorIds]); - - - // 파일 업로드 상태 관리 - const [attachments, setAttachments] = useState<File[]>([]) - const [isSubmitting, setIsSubmitting] = useState(false) - - // 벤더 선택을 위한 팝오버 상태 - const [vendorOpen, setVendorOpen] = useState(false) - - const form = useForm<VendorFormValues>({ - resolver: zodResolver(vendorFormSchema), - defaultValues: { - vendorId: "", - currency: "", - paymentTermsCode: "", - incotermsCode: "", - incotermsDetail: "", - deliveryDate: "", - taxCode: "", - placeOfShipping: "", - placeOfDestination: "", - materialPriceRelatedYn: false, - }, - }) - - // 폼 제출 핸들러 - async function onSubmit(values: VendorFormValues) { - if (!selectedRfq) { - toast.error("선택된 RFQ가 없습니다") - return - } - - try { - setIsSubmitting(true) - - // FormData 생성 - const formData = new FormData() - formData.append("rfqId", selectedRfq.id.toString()) - - // 폼 데이터 추가 - Object.entries(values).forEach(([key, value]) => { - formData.append(key, value.toString()) - }) - - // 첨부파일 추가 - attachments.forEach((file, index) => { - formData.append(`attachment-${index}`, file) - }) - - // 서버 액션 호출 - const result = await addVendorToRfq(formData) - - if (result.success) { - toast.success("벤더가 성공적으로 추가되었습니다") - onOpenChange(false) - form.reset() - setAttachments([]) - onSuccess?.() - } else { - toast.error(result.message || "벤더 추가 중 오류가 발생했습니다") - } - } catch (error) { - console.error("벤더 추가 오류:", error) - toast.error("벤더 추가 중 오류가 발생했습니다") - } finally { - setIsSubmitting(false) - } - } - - // 파일 업로드 핸들러 - const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { - if (event.target.files && event.target.files.length > 0) { - const newFiles = Array.from(event.target.files) - setAttachments((prev) => [...prev, ...newFiles]) - } - } - - // 파일 삭제 핸들러 - const handleRemoveFile = (index: number) => { - setAttachments((prev) => prev.filter((_, i) => i !== index)) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */} - <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden" style={{maxHeight:'85vh'}}> - {/* 고정 헤더 */} - <div className="p-6 border-b"> - <DialogHeader> - <DialogTitle>벤더 추가</DialogTitle> - <DialogDescription> - {selectedRfq ? ( - <> - <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다. - </> - ) : ( - "RFQ에 벤더를 추가합니다." - )} - </DialogDescription> - </DialogHeader> - </div> - - {/* 스크롤 가능한 콘텐츠 영역 */} - <div className="flex-1 overflow-y-auto p-6"> - <Form {...form}> - <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 검색 가능한 벤더 선택 필드 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <RequiredLabel>벤더</RequiredLabel> - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className={cn( - "w-full justify-between", - !field.value && "text-muted-foreground" - )} - > - {field.value - ? vendors.find((vendor) => String(vendor.id) === field.value) - ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` - : "벤더를 선택하세요" - : "벤더를 선택하세요"} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput placeholder="벤더 검색..." /> - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> - <CommandList> - <ScrollArea className="h-60"> - <CommandGroup> - {availableVendors.length > 0 ? ( - availableVendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorName} ${vendor.vendorCode}`} - onSelect={() => { - form.setValue("vendorId", String(vendor.id), { - shouldValidate: true, - }) - setVendorOpen(false) - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - String(vendor.id) === field.value - ? "opacity-100" - : "opacity-0" - )} - /> - {vendor.vendorName} ({vendor.vendorCode}) - </CommandItem> - )) - ) : ( - <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem> - )} - </CommandGroup> - </ScrollArea> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <RequiredLabel>통화</RequiredLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.name} ({currency.code}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="paymentTermsCode" - render={({ field }) => ( - <FormItem> - <RequiredLabel>지불 조건</RequiredLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="지불 조건 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {paymentTerms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem> - <RequiredLabel>인코텀즈</RequiredLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {incoterms.map((incoterm) => ( - <SelectItem key={incoterm.code} value={incoterm.code}> - {incoterm.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - {/* 나머지 필드들은 동일하게 유지 */} - <FormField - control={form.control} - name="incotermsDetail" - render={({ field }) => ( - <FormItem> - <FormLabel>인코텀즈 세부사항</FormLabel> - <FormControl> - <Input {...field} placeholder="인코텀즈 세부사항" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="deliveryDate" - render={({ field }) => ( - <FormItem> - <FormLabel>납품 예정일</FormLabel> - <FormControl> - <Input {...field} type="date" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="taxCode" - render={({ field }) => ( - <FormItem> - <FormLabel>세금 코드</FormLabel> - <FormControl> - <Input {...field} placeholder="세금 코드" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="placeOfShipping" - render={({ field }) => ( - <FormItem> - <FormLabel>선적지</FormLabel> - <FormControl> - <Input {...field} placeholder="선적지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="placeOfDestination" - render={({ field }) => ( - <FormItem> - <FormLabel>도착지</FormLabel> - <FormControl> - <Input {...field} placeholder="도착지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="materialPriceRelatedYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> - <FormControl> - <input - type="checkbox" - checked={field.value} - onChange={field.onChange} - className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>하도급대금 연동제 여부</FormLabel> - </div> - </FormItem> - )} - /> - - {/* 파일 업로드 섹션 */} - <div className="space-y-2"> - <Label>첨부 파일</Label> - <div className="border rounded-md p-4"> - <div className="flex items-center justify-center w-full"> - <label - htmlFor="file-upload" - className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100" - > - <div className="flex flex-col items-center justify-center pt-5 pb-6"> - <Upload className="w-8 h-8 mb-2 text-gray-500" /> - <p className="mb-2 text-sm text-gray-500"> - <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요 - </p> - <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p> - </div> - <input - id="file-upload" - type="file" - className="hidden" - multiple - onChange={handleFileUpload} - /> - </label> - </div> - - {/* 업로드된 파일 목록 */} - {attachments.length > 0 && ( - <div className="mt-4 space-y-2"> - <h4 className="text-sm font-medium">업로드된 파일</h4> - <ul className="space-y-2"> - {attachments.map((file, index) => ( - <li - key={index} - className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md" - > - <div className="flex items-center space-x-2"> - <File className="w-4 h-4 text-gray-500" /> - <span className="truncate max-w-[250px]">{file.name}</span> - <span className="text-gray-500 text-xs"> - ({(file.size / 1024).toFixed(1)} KB) - </span> - </div> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => handleRemoveFile(index)} - > - <X className="w-4 h-4 text-gray-500" /> - </Button> - </li> - ))} - </ul> - </div> - )} - </div> - </div> - </form> - </Form> - </div> - - {/* 고정 푸터 */} - <div className="p-6 border-t"> - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button - type="submit" - form="vendor-form" - disabled={isSubmitting} - > - {isSubmitting ? "처리 중..." : "벤더 추가"} - </Button> - </DialogFooter> - </div> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx deleted file mode 100644 index 49d982e1..00000000 --- a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client" - -import * as React from "react" -import { type RfqDetailView } from "./rfq-detail-column" -import { type Row } from "@tanstack/react-table" -import { Loader, Trash } from "lucide-react" -import { toast } from "sonner" - -import { useMediaQuery } from "@/hooks/use-media-query" -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer" -import { deleteRfqDetail } from "@/lib/procurement-rfqs/services" - - -interface DeleteRfqDetailDialogProps - extends React.ComponentPropsWithoutRef<typeof Dialog> { - detail: RfqDetailView | null - showTrigger?: boolean - onSuccess?: () => void -} - -export function DeleteRfqDetailDialog({ - detail, - showTrigger = true, - onSuccess, - ...props -}: DeleteRfqDetailDialogProps) { - const [isDeletePending, startDeleteTransition] = React.useTransition() - const isDesktop = useMediaQuery("(min-width: 640px)") - - function onDelete() { - if (!detail) return - - startDeleteTransition(async () => { - try { - const result = await deleteRfqDetail(detail.detailId) - - if (!result.success) { - toast.error(result.message || "삭제 중 오류가 발생했습니다") - return - } - - props.onOpenChange?.(false) - toast.success("RFQ 벤더 정보가 삭제되었습니다") - onSuccess?.() - } catch (error) { - console.error("RFQ 벤더 삭제 오류:", error) - toast.error("삭제 중 오류가 발생했습니다") - } - }) - } - - if (isDesktop) { - return ( - <Dialog {...props}> - {showTrigger ? ( - <DialogTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 - </Button> - </DialogTrigger> - ) : null} - <DialogContent> - <DialogHeader> - <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> - <DialogDescription> - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - </DialogDescription> - </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> - <DialogClose asChild> - <Button variant="outline">취소</Button> - </DialogClose> - <Button - aria-label="선택한 RFQ 벤더 정보 삭제" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 삭제 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) - } - - return ( - <Drawer {...props}> - {showTrigger ? ( - <DrawerTrigger asChild> - <Button variant="destructive" size="sm"> - <Trash className="mr-2 size-4" aria-hidden="true" /> - 삭제 - </Button> - </DrawerTrigger> - ) : null} - <DrawerContent> - <DrawerHeader> - <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> - <DrawerDescription> - 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. - </DrawerDescription> - </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> - <DrawerClose asChild> - <Button variant="outline">취소</Button> - </DrawerClose> - <Button - aria-label="선택한 RFQ 벤더 정보 삭제" - variant="destructive" - onClick={onDelete} - disabled={isDeletePending} - > - {isDeletePending && ( - <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> - )} - 삭제 - </Button> - </DrawerFooter> - </DrawerContent> - </Drawer> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx deleted file mode 100644 index bc257202..00000000 --- a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx +++ /dev/null @@ -1,393 +0,0 @@ -"use client" - -import * as React from "react" -import type { ColumnDef, Row } from "@tanstack/react-table"; -import { formatDate, formatDateTime } from "@/lib/utils" -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Ellipsis, MessageCircle, ExternalLink } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; - -export interface DataTableRowAction<TData> { - row: Row<TData>; - type: "delete" | "update" | "communicate"; // communicate 타입 추가 -} - -// procurementRfqDetailsView 타입 정의 (DB 스키마에 맞게 조정 필요) -export interface RfqDetailView { - detailId: number - rfqId: number - rfqCode: string - vendorId?: number | null // 벤더 ID 필드 추가 - projectCode: string | null - projectName: string | null - vendorCountry: string | null - itemCode: string | null - itemName: string | null - vendorName: string | null - vendorCode: string | null - currency: string | null - paymentTermsCode: string | null - paymentTermsDescription: string | null - incotermsCode: string | null - incotermsDescription: string | null - incotermsDetail: string | null - deliveryDate: Date | null - taxCode: string | null - placeOfShipping: string | null - placeOfDestination: string | null - materialPriceRelatedYn: boolean | null - hasQuotation: boolean | null - updatedByUserName: string | null - quotationStatus: string | null - updatedAt: Date | null - prItemsCount: number - majorItemsCount: number - quotationVersion:number | null - // 커뮤니케이션 관련 필드 추가 - commentCount?: number // 전체 코멘트 수 - unreadCount?: number // 읽지 않은 코멘트 수 - lastCommentDate?: Date // 마지막 코멘트 날짜 -} - -interface GetColumnsProps<TData> { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<TData> | null> - >; - unreadMessages?: Record<number, number>; // 벤더 ID별 읽지 않은 메시지 수 -} - -export function getRfqDetailColumns({ - setRowAction, - unreadMessages = {}, -}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] { - return [ - { - accessorKey: "quotationStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="견적 상태" /> - ), - cell: ({ row }) => <div>{row.getValue("quotationStatus")}</div>, - meta: { - excelHeader: "견적 상태" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "quotationVersion", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="견적 버전" /> - ), - cell: ({ row }) => <div>{row.getValue("quotationVersion")}</div>, - meta: { - excelHeader: "견적 버전" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>, - meta: { - excelHeader: "벤더 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "vendorName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더명" /> - ), - cell: ({ row }) => { - const vendorName = row.getValue("vendorName") as string; - const vendorId = row.original.vendorId; - - if (!vendorName || !vendorId) { - return <div>{vendorName}</div>; - } - - const handleVendorClick = () => { - window.open(`/evcp/vendors/${vendorId}/info`, '_blank'); - }; - - return ( - <Button - variant="link" - className="h-auto p-0 text-left justify-start font-normal text-foreground underline-offset-4 hover:underline" - onClick={handleVendorClick} - > - <span className="flex items-center gap-1"> - {vendorName} - {/* <ExternalLink className="h-3 w-3 opacity-50" /> */} - </span> - </Button> - ); - }, - meta: { - excelHeader: "벤더명" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "vendorType", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="내외자" /> - ), - cell: ({ row }) => <div>{row.original.vendorCountry === "KR"?"D":"F"}</div>, - meta: { - excelHeader: "내외자" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "currency", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="통화" /> - ), - cell: ({ row }) => <div>{row.getValue("currency")}</div>, - meta: { - excelHeader: "통화" - }, - enableResizing: true, - size: 80, - }, - { - accessorKey: "paymentTermsCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="지불 조건 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("paymentTermsCode")}</div>, - meta: { - excelHeader: "지불 조건 코드" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "paymentTermsDescription", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="지불 조건" /> - ), - cell: ({ row }) => <div>{row.getValue("paymentTermsDescription")}</div>, - meta: { - excelHeader: "지불 조건" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "incotermsCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="인코텀스 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("incotermsCode")}</div>, - meta: { - excelHeader: "인코텀스 코드" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "incotermsDescription", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="인코텀스" /> - ), - cell: ({ row }) => <div>{row.getValue("incotermsDescription")}</div>, - meta: { - excelHeader: "인코텀스" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "incotermsDetail", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="인코텀스 상세" /> - ), - cell: ({ row }) => <div>{row.getValue("incotermsDetail")}</div>, - meta: { - excelHeader: "인코텀스 상세" - }, - enableResizing: true, - size: 160, - }, - { - accessorKey: "deliveryDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="납품일" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDate(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "납품일" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "taxCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="세금 코드" /> - ), - cell: ({ row }) => <div>{row.getValue("taxCode")}</div>, - meta: { - excelHeader: "세금 코드" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "placeOfShipping", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="선적지" /> - ), - cell: ({ row }) => <div>{row.getValue("placeOfShipping")}</div>, - meta: { - excelHeader: "선적지" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "placeOfDestination", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="도착지" /> - ), - cell: ({ row }) => <div>{row.getValue("placeOfDestination")}</div>, - meta: { - excelHeader: "도착지" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "materialPriceRelatedYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="하도급대금 연동" /> - ), - cell: ({ row }) => <div>{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}</div>, - meta: { - excelHeader: "하도급대금 연동" - }, - enableResizing: true, - size: 140, - }, - { - accessorKey: "updatedByUserName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="수정자" /> - ), - cell: ({ row }) => <div>{row.getValue("updatedByUserName")}</div>, - meta: { - excelHeader: "수정자" - }, - enableResizing: true, - size: 120, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="수정일시" /> - ), - cell: ({ cell }) => { - const value = cell.getValue(); - return value ? formatDateTime(value as Date, "KR") : ""; - }, - meta: { - excelHeader: "수정일시" - }, - enableResizing: true, - size: 140, - }, - // 커뮤니케이션 컬럼 추가 - { - id: "communication", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="커뮤니케이션" /> - ), - cell: ({ row }) => { - const vendorId = row.original.vendorId || 0; - const unreadCount = unreadMessages[vendorId] || 0; - - return ( - <Button - variant="ghost" - size="sm" - className="relative p-0 h-8 w-8 flex items-center justify-center" - onClick={() => setRowAction({ row, type: "communicate" })} - > - <MessageCircle className="h-4 w-4" /> - {unreadCount > 0 && ( - <Badge - variant="destructive" - className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs" - > - {unreadCount} - </Badge> - )} - </Button> - ); - }, - enableResizing: false, - size: 80, - }, - { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-7 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - Delete - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } - ] -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx deleted file mode 100644 index ad9a19e7..00000000 --- a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx +++ /dev/null @@ -1,521 +0,0 @@ -"use client" - -import * as React from "react" -import { useEffect, useState } from "react" -import { - DataTableRowAction, - getRfqDetailColumns, - RfqDetailView -} from "./rfq-detail-column" -import { toast } from "sonner" - -import { Skeleton } from "@/components/ui/skeleton" -import { Card, CardContent } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { ProcurementRfqsView } from "@/db/schema" -import { - fetchCurrencies, - fetchIncoterms, - fetchPaymentTerms, - fetchRfqDetails, - fetchVendors, - fetchUnreadMessages -} from "@/lib/procurement-rfqs/services" -import { ClientDataTable } from "@/components/client-data-table/data-table" -import { AddVendorDialog } from "./add-vendor-dialog" -import { Button } from "@/components/ui/button" -import { Loader2, UserPlus, BarChart2 } from "lucide-react" // 아이콘 추가 -import { DeleteRfqDetailDialog } from "./delete-vendor-dialog" -import { UpdateRfqDetailSheet } from "./update-vendor-sheet" -import { VendorCommunicationDrawer } from "./vendor-communication-drawer" -import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" // 새로운 컴포넌트 임포트 - -// 프로퍼티 정의 -interface RfqDetailTablesProps { - selectedRfq: ProcurementRfqsView | null - maxHeight?: string | number -} - -// 데이터 타입 정의 -interface Vendor { - id: number; - vendorName: string; - vendorCode: string | null; // Update this to allow null - // 기타 필요한 벤더 속성들 -} - -interface Currency { - code: string; - name: string; -} - -interface PaymentTerm { - code: string; - description: string; -} - -interface Incoterm { - code: string; - description: string; -} - -export function RfqDetailTables({ selectedRfq , maxHeight}: RfqDetailTablesProps) { - - console.log("selectedRfq", selectedRfq) - // 상태 관리 - const [isLoading, setIsLoading] = useState(false) - const [isRefreshing, setIsRefreshing] = useState(false) - const [details, setDetails] = useState<RfqDetailView[]>([]) - const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) - const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) - const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) - const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null) - - const [vendors, setVendors] = React.useState<Vendor[]>([]) - const [currencies, setCurrencies] = React.useState<Currency[]>([]) - const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([]) - const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) - const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null) - - // 벤더 커뮤니케이션 상태 관리 - const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) - const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null) - - // 읽지 않은 메시지 개수 - const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({}) - const [isUnreadLoading, setIsUnreadLoading] = useState(false) - - // 견적 비교 다이얼로그 상태 관리 (추가) - const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false) - - const existingVendorIds = React.useMemo(() => { - return details.map(detail => Number(detail.vendorId)).filter(Boolean); - }, [details]); - - const handleAddVendor = async () => { - try { - setIsAdddialogLoading(true) - - // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) - const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ - fetchVendors(), - fetchCurrencies(), - fetchPaymentTerms(), - fetchIncoterms() - ]) - - setVendors(vendorsData.data || []) - setCurrencies(currenciesData.data || []) - setPaymentTerms(paymentTermsData.data || []) - setIncoterms(incotermsData.data || []) - - setVendorDialogOpen(true) - } catch (error) { - console.error("데이터 로드 오류:", error) - toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsAdddialogLoading(false) - } - } - - // 견적 비교 다이얼로그 열기 핸들러 (추가) - const handleOpenComparisonDialog = () => { - // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 - const hasSubmittedQuotations = details.some(detail => - detail.hasQuotation && detail.quotationStatus === "Submitted" - ); - - if (!hasSubmittedQuotations) { - toast.warning("제출된 견적이 없습니다."); - return; - } - - setComparisonDialogOpen(true); - } - - // 읽지 않은 메시지 로드 - const loadUnreadMessages = async () => { - if (!selectedRfq || !selectedRfq.id) return; - - try { - setIsUnreadLoading(true); - - // 읽지 않은 메시지 수 가져오기 - const unreadData = await fetchUnreadMessages(selectedRfq.id); - setUnreadMessages(unreadData); - } catch (error) { - console.error("읽지 않은 메시지 로드 오류:", error); - // 조용히 실패 - 사용자에게 알림 표시하지 않음 - } finally { - setIsUnreadLoading(false); - } - }; - - // 칼럼 정의 - unreadMessages 상태 전달 - const columns = React.useMemo(() => - getRfqDetailColumns({ - setRowAction, - unreadMessages - }), [unreadMessages]) - - // 필터 필드 정의 (필터 사용 시) - const advancedFilterFields = React.useMemo( - () => [ - { - id: "vendorName", - label: "벤더명", - type: "text", - }, - { - id: "vendorCode", - label: "벤더 코드", - type: "text", - }, - { - id: "currency", - label: "통화", - type: "text", - }, - ], - [] - ) - - // RFQ ID가 변경될 때 데이터 로드 - useEffect(() => { - async function loadRfqDetails() { - if (!selectedRfq || !selectedRfq.id) { - setDetails([]) - return - } - - try { - setIsLoading(true) - const transformRfqDetails = (data: any[]): RfqDetailView[] => { - return data.map(item => ({ - ...item, - // Convert vendorId from string|null to number|undefined - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - // Transform any other fields that need type conversion - })); - }; - - // Then in your useEffect: - const result = await fetchRfqDetails(selectedRfq.id); - setDetails(transformRfqDetails(result.data)); - - // 읽지 않은 메시지 개수 로드 - await loadUnreadMessages(); - } catch (error) { - console.error("RFQ 디테일 로드 오류:", error) - setDetails([]) - toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다") - } finally { - setIsLoading(false) - } - } - - loadRfqDetails() - }, [selectedRfq]) - - // 주기적으로 읽지 않은 메시지 갱신 (60초마다) - useEffect(() => { - if (!selectedRfq || !selectedRfq.id) return; - - const intervalId = setInterval(() => { - loadUnreadMessages(); - }, 60000); // 60초마다 갱신 - - return () => clearInterval(intervalId); - }, [selectedRfq]); - - // rowAction 처리 - useEffect(() => { - if (!rowAction) return - - const handleRowAction = async () => { - try { - // 통신 액션인 경우 드로어 열기 - if (rowAction.type === "communicate") { - setSelectedVendor(rowAction.row.original); - setCommunicationDrawerOpen(true); - - // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주) - const vendorId = rowAction.row.original.vendorId; - if (vendorId) { - setUnreadMessages(prev => ({ - ...prev, - [vendorId]: 0 - })); - } - - // rowAction 초기화 - setRowAction(null); - return; - } - - // 다른 액션들은 기존과 동일하게 처리 - setIsAdddialogLoading(true); - - // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) - const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ - fetchVendors(), - fetchCurrencies(), - fetchPaymentTerms(), - fetchIncoterms() - ]); - - setVendors(vendorsData.data || []); - setCurrencies(currenciesData.data || []); - setPaymentTerms(paymentTermsData.data || []); - setIncoterms(incotermsData.data || []); - - // 이제 데이터가 로드되었으므로 필요한 작업 수행 - if (rowAction.type === "update") { - setSelectedDetail(rowAction.row.original); - setUpdateSheetOpen(true); - } else if (rowAction.type === "delete") { - setSelectedDetail(rowAction.row.original); - setDeleteDialogOpen(true); - } - } catch (error) { - console.error("데이터 로드 오류:", error); - toast.error("데이터를 불러오는 중 오류가 발생했습니다"); - } finally { - // communicate 타입이 아닌 경우에만 로딩 상태 변경 - if (rowAction && rowAction.type !== "communicate") { - setIsAdddialogLoading(false); - } - } - }; - - handleRowAction(); - }, [rowAction]) - - // RFQ가 선택되지 않은 경우 - if (!selectedRfq) { - return ( - <div className="flex h-full items-center justify-center text-muted-foreground"> - RFQ를 선택하세요 - </div> - ) - } - - // 로딩 중인 경우 - if (isLoading) { - return ( - <div className="p-4 space-y-4"> - <Skeleton className="h-8 w-1/2" /> - <Skeleton className="h-24 w-full" /> - <Skeleton className="h-48 w-full" /> - </div> - ) - } - - const handleRefreshData = async () => { - if (!selectedRfq || !selectedRfq.id) return - - try { - setIsRefreshing(true) - - const transformRfqDetails = (data: any[]): RfqDetailView[] => { - return data.map(item => ({ - ...item, - // Convert vendorId from string|null to number|undefined - vendorId: item.vendorId ? Number(item.vendorId) : undefined, - // Transform any other fields that need type conversion - })); - }; - - // Then in your useEffect: - const result = await fetchRfqDetails(selectedRfq.id); - setDetails(transformRfqDetails(result.data)); - - // 읽지 않은 메시지 개수 업데이트 - await loadUnreadMessages(); - - toast.success("데이터가 새로고침되었습니다") - } catch (error) { - console.error("RFQ 디테일 로드 오류:", error) - toast.error("데이터 새로고침 중 오류가 발생했습니다") - } finally { - setIsRefreshing(false) - } - } - - // 전체 읽지 않은 메시지 수 계산 - const totalUnreadMessages = Object.values(unreadMessages).reduce((sum, count) => sum + count, 0); - - // 견적이 있는 벤더 수 계산 - const vendorsWithQuotations = details.filter(detail => detail.hasQuotation && detail.quotationStatus === "Submitted").length; - - return ( - <div className="h-full overflow-hidden pt-4"> - - {/* 메시지 및 새로고침 영역 */} - - - {/* 테이블 또는 빈 상태 표시 */} - {details.length > 0 ? ( - - <ClientDataTable - columns={columns} - data={details} - advancedFilterFields={advancedFilterFields} - maxHeight={maxHeight} - > - - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2 mr-2"> - {totalUnreadMessages > 0 && ( - <Badge variant="destructive" className="h-6"> - 읽지 않은 메시지: {totalUnreadMessages}건 - </Badge> - )} - {vendorsWithQuotations > 0 && ( - <Badge variant="outline" className="h-6"> - 견적 제출: {vendorsWithQuotations}개 벤더 - </Badge> - )} - </div> - <div className="flex gap-2"> - {/* 견적 비교 버튼 추가 */} - <Button - variant="outline" - size="sm" - onClick={handleOpenComparisonDialog} - className="gap-2" - disabled={ - !selectedRfq || - details.length === 0 || - (!!selectedRfq.rfqSealedYn && selectedRfq.dueDate && new Date() < new Date(selectedRfq.dueDate)) - } - > - <BarChart2 className="size-4" aria-hidden="true" /> - <span>견적 비교</span> - </Button> - <Button - variant="outline" - size="sm" - onClick={handleRefreshData} - disabled={isRefreshing} - > - {isRefreshing ? ( - <> - <Loader2 className="h-4 w-4 mr-2 animate-spin" /> - 새로고침 중... - </> - ) : ( - '새로고침' - )} - </Button> - </div> - </div> - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - className="gap-2" - disabled={!selectedRfq || isAdddialogLoading} - > - {isAdddialogLoading ? ( - <> - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - <span>로딩 중...</span> - </> - ) : ( - <> - <UserPlus className="size-4" aria-hidden="true" /> - <span>벤더 추가</span> - </> - )} - </Button> - </ClientDataTable> - - ) : ( - <div className="flex h-48 items-center justify-center text-muted-foreground border rounded-md p-4"> - <div className="flex flex-col items-center gap-4"> - <p>해당 RFQ에 대한 협력업체가 정해지지 않았습니다. 아래 버튼을 이용하여 추가하시기 바랍니다.</p> - <Button - variant="outline" - size="sm" - onClick={handleAddVendor} - className="gap-2" - disabled={!selectedRfq || isAdddialogLoading} - > - {isAdddialogLoading ? ( - <> - <Loader2 className="size-4 animate-spin" aria-hidden="true" /> - <span>로딩 중...</span> - </> - ) : ( - <> - <UserPlus className="size-4" aria-hidden="true" /> - <span>협력업체 추가</span> - </> - )} - </Button> - </div> - </div> - )} - - {/* 벤더 추가 다이얼로그 */} - <AddVendorDialog - open={vendorDialogOpen} - onOpenChange={(open) => { - setVendorDialogOpen(open); - if (!open) setIsAdddialogLoading(false); - }} - selectedRfq={selectedRfq} - vendors={vendors} - currencies={currencies} - paymentTerms={paymentTerms} - incoterms={incoterms} - onSuccess={handleRefreshData} - existingVendorIds={existingVendorIds} - /> - - {/* 벤더 정보 수정 시트 */} - <UpdateRfqDetailSheet - open={updateSheetOpen} - onOpenChange={setUpdateSheetOpen} - detail={selectedDetail} - vendors={vendors} - currencies={currencies} - paymentTerms={paymentTerms} - incoterms={incoterms} - onSuccess={handleRefreshData} - /> - - {/* 벤더 정보 삭제 다이얼로그 */} - <DeleteRfqDetailDialog - open={deleteDialogOpen} - onOpenChange={setDeleteDialogOpen} - detail={selectedDetail} - showTrigger={false} - onSuccess={handleRefreshData} - /> - - {/* 벤더 커뮤니케이션 드로어 */} - <VendorCommunicationDrawer - open={communicationDrawerOpen} - onOpenChange={(open) => { - setCommunicationDrawerOpen(open); - // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 - if (!open) loadUnreadMessages(); - }} - selectedRfq={selectedRfq} - selectedVendor={selectedVendor} - onSuccess={handleRefreshData} - /> - - {/* 견적 비교 다이얼로그 추가 */} - <VendorQuotationComparisonDialog - open={comparisonDialogOpen} - onOpenChange={setComparisonDialogOpen} - selectedRfq={selectedRfq} - /> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx deleted file mode 100644 index edc04788..00000000 --- a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx +++ /dev/null @@ -1,449 +0,0 @@ -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { Check, ChevronsUpDown, Loader } from "lucide-react" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { z } from "zod" - -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, -} from "@/components/ui/command" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Checkbox } from "@/components/ui/checkbox" -import { ScrollArea } from "@/components/ui/scroll-area" - -import { RfqDetailView } from "./rfq-detail-column" -import { updateRfqDetail } from "@/lib/procurement-rfqs/services" - -// 폼 유효성 검증 스키마 -const updateRfqDetailSchema = z.object({ - vendorId: z.string().min(1, "벤더를 선택해주세요"), - currency: z.string().min(1, "통화를 선택해주세요"), - paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), - incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), - incotermsDetail: z.string().optional(), - deliveryDate: z.string().optional(), - taxCode: z.string().optional(), - placeOfShipping: z.string().optional(), - placeOfDestination: z.string().optional(), - materialPriceRelatedYn: z.boolean().default(false), -}) - -type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema> - -// 데이터 타입 정의 -interface Vendor { - id: number; - vendorName: string; - vendorCode: string; -} - -interface Currency { - code: string; - name: string; -} - -interface PaymentTerm { - code: string; - description: string; -} - -interface Incoterm { - code: string; - description: string; -} - -interface UpdateRfqDetailSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - detail: RfqDetailView | null; - vendors: Vendor[]; - currencies: Currency[]; - paymentTerms: PaymentTerm[]; - incoterms: Incoterm[]; - onSuccess?: () => void; -} - -export function UpdateRfqDetailSheet({ - detail, - vendors, - currencies, - paymentTerms, - incoterms, - onSuccess, - ...props -}: UpdateRfqDetailSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const [vendorOpen, setVendorOpen] = React.useState(false) - - const form = useForm<UpdateRfqDetailFormValues>({ - resolver: zodResolver(updateRfqDetailSchema), - defaultValues: { - vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "", - currency: detail?.currency || "", - paymentTermsCode: detail?.paymentTermsCode || "", - incotermsCode: detail?.incotermsCode || "", - incotermsDetail: detail?.incotermsDetail || "", - deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", - taxCode: detail?.taxCode || "", - placeOfShipping: detail?.placeOfShipping || "", - placeOfDestination: detail?.placeOfDestination || "", - materialPriceRelatedYn: detail?.materialPriceRelatedYn || false, - }, - }) - - // detail이 변경될 때 form 값 업데이트 - React.useEffect(() => { - if (detail) { - const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id - - form.reset({ - vendorId: vendorId ? String(vendorId) : "", - currency: detail.currency || "", - paymentTermsCode: detail.paymentTermsCode || "", - incotermsCode: detail.incotermsCode || "", - incotermsDetail: detail.incotermsDetail || "", - deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", - taxCode: detail.taxCode || "", - placeOfShipping: detail.placeOfShipping || "", - placeOfDestination: detail.placeOfDestination || "", - materialPriceRelatedYn: detail.materialPriceRelatedYn || false, - }) - } - }, [detail, form, vendors]) - - function onSubmit(values: UpdateRfqDetailFormValues) { - if (!detail) return - - startUpdateTransition(async () => { - try { - const result = await updateRfqDetail(detail.detailId, values) - - if (!result.success) { - toast.error(result.message || "수정 중 오류가 발생했습니다") - return - } - - props.onOpenChange?.(false) - toast.success("RFQ 벤더 정보가 수정되었습니다") - onSuccess?.() - } catch (error) { - console.error("RFQ 벤더 수정 오류:", error) - toast.error("수정 중 오류가 발생했습니다") - } - }) - } - - return ( - <Sheet {...props}> - <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl"> - <SheetHeader className="text-left"> - <SheetTitle>RFQ 벤더 정보 수정</SheetTitle> - <SheetDescription> - 벤더 정보를 수정하고 저장하세요 - </SheetDescription> - </SheetHeader> - <ScrollArea className="flex-1 pr-4"> - <Form {...form}> - <form - id="update-rfq-detail-form" - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4" - > - {/* 검색 가능한 벤더 선택 필드 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel> - <Popover open={vendorOpen} onOpenChange={setVendorOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorOpen} - className={cn( - "w-full justify-between", - !field.value && "text-muted-foreground" - )} - > - {field.value - ? vendors.find((vendor) => String(vendor.id) === field.value) - ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` - : "벤더를 선택하세요" - : "벤더를 선택하세요"} - <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-[400px] p-0"> - <Command> - <CommandInput placeholder="벤더 검색..." /> - <CommandEmpty>검색 결과가 없습니다</CommandEmpty> - <ScrollArea className="h-60"> - <CommandGroup> - {vendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorName} ${vendor.vendorCode}`} - onSelect={() => { - form.setValue("vendorId", String(vendor.id), { - shouldValidate: true, - }) - setVendorOpen(false) - }} - > - <Check - className={cn( - "mr-2 h-4 w-4", - String(vendor.id) === field.value - ? "opacity-100" - : "opacity-0" - )} - /> - {vendor.vendorName} ({vendor.vendorCode}) - </CommandItem> - ))} - </CommandGroup> - </ScrollArea> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel>통화 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화를 선택하세요" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {currencies.map((currency) => ( - <SelectItem key={currency.code} value={currency.code}> - {currency.name} ({currency.code}) - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="paymentTermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="지불 조건 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {paymentTerms.map((term) => ( - <SelectItem key={term.code} value={term.code}> - {term.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="incotermsCode" - render={({ field }) => ( - <FormItem> - <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel> - <Select onValueChange={field.onChange} value={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="인코텀즈 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {incoterms.map((incoterm) => ( - <SelectItem key={incoterm.code} value={incoterm.code}> - {incoterm.description} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="incotermsDetail" - render={({ field }) => ( - <FormItem> - <FormLabel>인코텀즈 세부사항</FormLabel> - <FormControl> - <Input {...field} placeholder="인코텀즈 세부사항" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="deliveryDate" - render={({ field }) => ( - <FormItem> - <FormLabel>납품 예정일</FormLabel> - <FormControl> - <Input {...field} type="date" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="taxCode" - render={({ field }) => ( - <FormItem> - <FormLabel>세금 코드</FormLabel> - <FormControl> - <Input {...field} placeholder="세금 코드" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="placeOfShipping" - render={({ field }) => ( - <FormItem> - <FormLabel>선적지</FormLabel> - <FormControl> - <Input {...field} placeholder="선적지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - <FormField - control={form.control} - name="placeOfDestination" - render={({ field }) => ( - <FormItem> - <FormLabel>도착지</FormLabel> - <FormControl> - <Input {...field} placeholder="도착지" /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <FormField - control={form.control} - name="materialPriceRelatedYn" - render={({ field }) => ( - <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> - <FormControl> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <div className="space-y-1 leading-none"> - <FormLabel>하도급 대금 연동 여부</FormLabel> - </div> - </FormItem> - )} - /> - </form> - </Form> - </ScrollArea> - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - 취소 - </Button> - </SheetClose> - <Button - type="submit" - form="update-rfq-detail-form" - disabled={isUpdatePending} - > - {isUpdatePending && ( - <Loader - className="mr-2 size-4 animate-spin" - aria-hidden="true" - /> - )} - 저장 - </Button> - </SheetFooter> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx deleted file mode 100644 index e43fc676..00000000 --- a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx +++ /dev/null @@ -1,518 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { ProcurementRfqsView } from "@/db/schema" -import { RfqDetailView } from "./rfq-detail-column" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Textarea } from "@/components/ui/textarea" -import { Avatar, AvatarFallback } from "@/components/ui/avatar" -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Badge } from "@/components/ui/badge" -import { toast } from "sonner" -import { - Send, - Paperclip, - DownloadCloud, - File, - FileText, - Image as ImageIcon, - AlertCircle, - X -} from "lucide-react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { formatDateTime } from "@/lib/utils" -import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트 -import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/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; - filePath: string; - uploadedAt: Date; -} - -// 프롭스 정의 -interface VendorCommunicationDrawerProps { - open: boolean; - onOpenChange: (open: boolean) => void; - selectedRfq: ProcurementRfqsView | null; - selectedVendor: RfqDetailView | null; - onSuccess?: () => void; -} - -async function sendComment(params: { - rfqId: number; - vendorId: number; - content: string; - attachments?: File[]; -}): Promise<Comment> { - try { - // 폼 데이터 생성 (파일 첨부를 위해) - const formData = new FormData(); - formData.append('rfqId', params.rfqId.toString()); - formData.append('vendorId', params.vendorId.toString()); - formData.append('content', params.content); - formData.append('isVendorComment', 'false'); - - // 첨부파일 추가 - if (params.attachments && params.attachments.length > 0) { - params.attachments.forEach((file) => { - formData.append(`attachments`, file); - }); - } - - // API 엔드포인트 구성 - const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; - - // API 호출 - const response = await fetch(url, { - method: 'POST', - body: formData, // multipart/form-data 형식 사용 - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`API 요청 실패: ${response.status} ${errorText}`); - } - - // 응답 데이터 파싱 - const result = await response.json(); - - if (!result.success || !result.data) { - throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); - } - - return result.data.comment; - } catch (error) { - console.error('코멘트 전송 오류:', error); - throw error; - } -} - -export function VendorCommunicationDrawer({ - open, - onOpenChange, - selectedRfq, - selectedVendor, - onSuccess -}: VendorCommunicationDrawerProps) { - // 상태 관리 - 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 && selectedRfq && selectedVendor) { - loadComments(); - } - }, [open, selectedRfq, selectedVendor]); - - // 스크롤 최하단으로 이동 - useEffect(() => { - if (messagesEndRef.current) { - messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); - } - }, [comments]); - - // 코멘트 로드 함수 - const loadComments = async () => { - if (!selectedRfq || !selectedVendor) return; - - try { - setIsLoading(true); - - // Server Action을 사용하여 코멘트 데이터 가져오기 - const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId); - setComments(commentsData); - - // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 - await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId); - } 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)); - }; - - console.log(newComment) - - // 코멘트 전송 핸들러 - const handleSubmitComment = async () => { - console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId ) - console.log(!newComment.trim() && attachments.length === 0) - - if (!newComment.trim() && attachments.length === 0) return; - if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return; - - console.log("버튼 클릭") - - try { - setIsSubmitting(true); - - // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) - const newCommentObj = await sendComment({ - rfqId: selectedRfq.id, - vendorId: selectedVendor.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) => { - // TODO: 실제 다운로드 구현 - 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, "KR")} - </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 (!selectedRfq || !selectedVendor) { - return null; - } - - 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"> - {selectedVendor.vendorName?.[0] || 'V'} - </AvatarFallback> - </Avatar> - <div> - <span>{selectedVendor.vendorName}</span> - <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge> - </div> - </DrawerTitle> - <DrawerDescription> - RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName} - </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-start' : 'justify-end'}`} - > - {comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/10"> - {comment.vendorName?.[0] || 'V'} - </AvatarFallback> - </Avatar> - )} - - <div className={`rounded-lg p-3 max-w-[80%] ${ - comment.isVendorComment - ? 'bg-muted' - : 'bg-primary text-primary-foreground' - }`}> - <div className="text-sm font-medium mb-1"> - {comment.isVendorComment ? comment.vendorName : comment.userName} - </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-border/30' - : 'border-t border-t-primary-foreground/20' - }`}> - {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, "KR")} - </div> - </div> - - {!comment.isVendorComment && ( - <Avatar className="h-8 w-8 mt-1"> - <AvatarFallback className="bg-primary/20"> - {comment.userName?.[0] || 'U'} - </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/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx deleted file mode 100644 index 72cf187c..00000000 --- a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx +++ /dev/null @@ -1,665 +0,0 @@ -"use client" - -import * as React from "react" -import { useEffect, useState } from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Skeleton } from "@/components/ui/skeleton" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table" -import { toast } from "sonner" - -// Lucide 아이콘 -import { Plus, Minus } from "lucide-react" - -import { ProcurementRfqsView } from "@/db/schema" -import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services" -import { formatCurrency, formatDate } from "@/lib/utils" - -// 견적 정보 타입 -interface VendorQuotation { - id: number - rfqId: number - vendorId: number - vendorName?: string | null - quotationCode: string - quotationVersion: number - totalItemsCount: number - subTotal: string - taxTotal: string - discountTotal: string - totalPrice: string - currency: string - validUntil: string | Date // 수정: string | Date 허용 - estimatedDeliveryDate: string | Date // 수정: string | Date 허용 - paymentTermsCode: string - paymentTermsDescription?: string | null - incotermsCode: string - incotermsDescription?: string | null - incotermsDetail: string - status: string - remark: string - rejectionReason: string - submittedAt: string | Date // 수정: string | Date 허용 - acceptedAt: string | Date // 수정: string | Date 허용 - createdAt: string | Date // 수정: string | Date 허용 - updatedAt: string | Date // 수정: string | Date 허용 -} - -// 견적 아이템 타입 -interface QuotationItem { - id: number - quotationId: number - prItemId: number - materialCode: string | null // Changed from string to string | null - materialDescription: string | null // Changed from string to string | null - quantity: string - uom: string | null // Changed assuming this might be null - unitPrice: string - totalPrice: string - currency: string | null // Changed from string to string | null - vendorMaterialCode: string | null // Changed from string to string | null - vendorMaterialDescription: string | null // Changed from string to string | null - deliveryDate: Date | null // Changed from string to string | null - leadTimeInDays: number | null // Changed from number to number | null - taxRate: string | null // Changed from string to string | null - taxAmount: string | null // Changed from string to string | null - discountRate: string | null // Changed from string to string | null - discountAmount: string | null // Changed from string to string | null - remark: string | null // Changed from string to string | null - isAlternative: boolean | null // Changed from boolean to boolean | null - isRecommended: boolean | null // Changed from boolean to boolean | null -} - -interface VendorQuotationComparisonDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedRfq: ProcurementRfqsView | null -} - -export function VendorQuotationComparisonDialog({ - open, - onOpenChange, - selectedRfq, -}: VendorQuotationComparisonDialogProps) { - const [isLoading, setIsLoading] = useState(false) - const [quotations, setQuotations] = useState<VendorQuotation[]>([]) - const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({}) - const [activeTab, setActiveTab] = useState("summary") - - // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘 - const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({}) - - useEffect(() => { - async function loadQuotationData() { - if (!open || !selectedRfq?.id) return - - try { - setIsLoading(true) - // 1) 견적 목록 - const quotationsResult = await fetchVendorQuotations(selectedRfq.id) - const rawQuotationsData = quotationsResult.data || [] - - const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({ - id: rawData.id, - rfqId: rawData.rfqId, - vendorId: rawData.vendorId, - vendorName: rawData.vendorName || null, - quotationCode: rawData.quotationCode || '', - quotationVersion: rawData.quotationVersion || 0, - totalItemsCount: rawData.totalItemsCount || 0, - subTotal: rawData.subTotal || '0', - taxTotal: rawData.taxTotal || '0', - discountTotal: rawData.discountTotal || '0', - totalPrice: rawData.totalPrice || '0', - currency: rawData.currency || 'KRW', - validUntil: rawData.validUntil || '', - estimatedDeliveryDate: rawData.estimatedDeliveryDate || '', - paymentTermsCode: rawData.paymentTermsCode || '', - paymentTermsDescription: rawData.paymentTermsDescription || null, - incotermsCode: rawData.incotermsCode || '', - incotermsDescription: rawData.incotermsDescription || null, - incotermsDetail: rawData.incotermsDetail || '', - status: rawData.status || '', - remark: rawData.remark || '', - rejectionReason: rawData.rejectionReason || '', - submittedAt: rawData.submittedAt || '', - acceptedAt: rawData.acceptedAt || '', - createdAt: rawData.createdAt || '', - updatedAt: rawData.updatedAt || '', - })); - - setQuotations(quotationsData); - - // 벤더별로 접힘 상태 기본값(true) 설정 - const collapsedInit: Record<number, boolean> = {} - quotationsData.forEach((q) => { - collapsedInit[q.id] = true - }) - setCollapsedVendors(collapsedInit) - - // 2) 견적 아이템 - const qIds = quotationsData.map((q) => q.id) - if (qIds.length > 0) { - const itemsResult = await fetchQuotationItems(qIds) - const itemsData = itemsResult.data || [] - - const itemsByQuotation: Record<number, QuotationItem[]> = {} - itemsData.forEach((item) => { - if (!itemsByQuotation[item.quotationId]) { - itemsByQuotation[item.quotationId] = [] - } - itemsByQuotation[item.quotationId].push(item) - }) - setQuotationItems(itemsByQuotation) - } - } catch (error) { - console.error("견적 데이터 로드 오류:", error) - toast.error("견적 데이터를 불러오는 데 실패했습니다") - } finally { - setIsLoading(false) - } - } - - loadQuotationData() - }, [open, selectedRfq]) - - // 견적 상태 -> 뱃지 색 - const getStatusBadgeVariant = (status: string) => { - switch (status) { - case "Submitted": - return "default" - case "Accepted": - return "default" - case "Rejected": - return "destructive" - case "Revised": - return "destructive" - default: - return "secondary" - } - } - - // 모든 prItemId 모음 - const allItemIds = React.useMemo(() => { - const itemSet = new Set<number>() - Object.values(quotationItems).forEach((items) => { - items.forEach((it) => itemSet.add(it.prItemId)) - }) - return Array.from(itemSet) - }, [quotationItems]) - - // 아이템 찾는 함수 - const findItemByQuotationId = (prItemId: number, qid: number) => { - const items = quotationItems[qid] || [] - return items.find((i) => i.prItemId === prItemId) - } - - // 접힘 상태 토글 - const toggleVendor = (qid: number) => { - setCollapsedVendors((prev) => ({ - ...prev, - [qid]: !prev[qid], - })) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */} - <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}> - <DialogHeader> - <DialogTitle>벤더 견적 비교</DialogTitle> - <DialogDescription> - {selectedRfq - ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}` - : ""} - </DialogDescription> - </DialogHeader> - - {isLoading ? ( - <div className="space-y-4"> - <Skeleton className="h-8 w-1/2" /> - <Skeleton className="h-48 w-full" /> - </div> - ) : quotations.length === 0 ? ( - <div className="py-8 text-center text-muted-foreground"> - 제출된(Submitted) 견적이 없습니다 - </div> - ) : ( - <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> - <TabsList className="grid w-full grid-cols-2"> - <TabsTrigger value="summary">견적 요약 비교</TabsTrigger> - <TabsTrigger value="items">아이템별 비교</TabsTrigger> - </TabsList> - - {/* ======================== 요약 비교 탭 ======================== */} - <TabsContent value="summary" className="mt-4"> - {/* - table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px]) - -> 컨테이너보다 넓으면 수평 스크롤 발생. - */} - <div className="border rounded-md max-h-[60vh] overflow-auto"> - <table className="table-fixed w-full border-collapse"> - <thead className="sticky top-0 bg-background z-10"> - <TableRow> - <TableHead - className="sticky left-0 top-0 z-20 bg-background p-2" - > - 항목 - </TableHead> - {quotations.map((q) => ( - <TableHead key={q.id} className="p-2 text-center whitespace-nowrap"> - {q.vendorName || `벤더 ID: ${q.vendorId}`} - </TableHead> - ))} - </TableRow> - </thead> - <tbody> - {/* 견적 상태 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 견적 상태 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`status-${q.id}`} className="p-2"> - <Badge variant={getStatusBadgeVariant(q.status)}> - {q.status} - </Badge> - </TableCell> - ))} - </TableRow> - - {/* 견적 버전 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 견적 버전 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`version-${q.id}`} className="p-2"> - v{q.quotationVersion} - </TableCell> - ))} - </TableRow> - - {/* 총 금액 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 총 금액 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`total-${q.id}`} className="p-2 font-semibold"> - {formatCurrency(Number(q.totalPrice), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 소계 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 소계 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`subtotal-${q.id}`} className="p-2"> - {formatCurrency(Number(q.subTotal), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 세금 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 세금 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`tax-${q.id}`} className="p-2"> - {formatCurrency(Number(q.taxTotal), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 할인 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 할인 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`discount-${q.id}`} className="p-2"> - {formatCurrency(Number(q.discountTotal), q.currency)} - </TableCell> - ))} - </TableRow> - - {/* 통화 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 통화 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`currency-${q.id}`} className="p-2"> - {q.currency} - </TableCell> - ))} - </TableRow> - - {/* 유효기간 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 유효 기간 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`valid-${q.id}`} className="p-2"> - {formatDate(q.validUntil, "KR")} - </TableCell> - ))} - </TableRow> - - {/* 예상 배송일 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 예상 배송일 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`delivery-${q.id}`} className="p-2"> - {formatDate(q.estimatedDeliveryDate, "KR")} - </TableCell> - ))} - </TableRow> - - {/* 지불 조건 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 지불 조건 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`payment-${q.id}`} className="p-2"> - {q.paymentTermsDescription || q.paymentTermsCode} - </TableCell> - ))} - </TableRow> - - {/* 인코텀즈 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 인코텀즈 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`incoterms-${q.id}`} className="p-2"> - {q.incotermsDescription || q.incotermsCode} - {q.incotermsDetail && ( - <div className="text-xs text-muted-foreground mt-1"> - {q.incotermsDetail} - </div> - )} - </TableCell> - ))} - </TableRow> - - {/* 제출일 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 제출일 - </TableCell> - {quotations.map((q) => ( - <TableCell key={`submitted-${q.id}`} className="p-2"> - {formatDate(q.submittedAt, "KR")} - </TableCell> - ))} - </TableRow> - - {/* 비고 */} - <TableRow> - <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> - 비고 - </TableCell> - {quotations.map((q) => ( - <TableCell - key={`remark-${q.id}`} - className="p-2 whitespace-pre-wrap" - > - {q.remark || "-"} - </TableCell> - ))} - </TableRow> - </tbody> - </table> - </div> - </TabsContent> - - {/* ====================== 아이템별 비교 탭 ====================== */} - <TabsContent value="items" className="mt-4"> - {/* 컨테이너에 테이블 관련 클래스 직접 적용 */} - <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" > - <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}> - <table className="w-full border-collapse"> - <thead className="sticky top-0 bg-background z-10"> - {/* 첫 번째 헤더 행 */} - <tr> - {/* 첫 행: 자재(코드) 컬럼 */} - <th - rowSpan={2} - className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left" - style={{ - width: '250px', - minWidth: '250px', - backgroundColor: 'white', - }} - > - 자재 (코드) - </th> - - {/* 벤더 헤더 (접힘/펼침) */} - {quotations.map((q, index) => { - const collapsed = collapsedVendors[q.id] - // 접힌 상태면 1칸, 펼친 상태면 6칸 - return ( - <th - key={q.id} - className="p-2 text-center whitespace-nowrap border border-gray-200" - colSpan={collapsed ? 1 : 6} - style={{ - borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '', - backgroundColor: 'white', - }} - > - {/* + / - 버튼 */} - <div className="flex items-center gap-2 justify-center"> - <Button - variant="ghost" - size="sm" - className="h-7 w-7 p-1" - onClick={() => toggleVendor(q.id)} - > - {collapsed ? <Plus size={16} /> : <Minus size={16} />} - </Button> - <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span> - </div> - </th> - ) - })} - </tr> - - {/* 두 번째 헤더 행 - 하위 컬럼들 */} - <tr className="border-b border-b-gray-200"> - {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */} - {quotations.flatMap((q, qIndex) => { - // 접힌 상태면 추가 헤더 없음 - if (collapsedVendors[q.id]) { - return [ - <th - key={`${q.id}-collapsed`} - className="p-2 text-center whitespace-nowrap border border-gray-200" - style={{ backgroundColor: 'white' }} - > - 총액 - </th> - ]; - } - - // 펼친 상태면 6개 컬럼 표시 - const columns = [ - { key: 'unitprice', label: '단가' }, - { key: 'totalprice', label: '총액' }, - { key: 'tax', label: '세금' }, - { key: 'discount', label: '할인' }, - { key: 'leadtime', label: '리드타임' }, - { key: 'alternative', label: '대체품' }, - ]; - - return columns.map((col, colIndex) => { - const isFirstInGroup = colIndex === 0; - const isLastInGroup = colIndex === columns.length - 1; - - return ( - <th - key={`${q.id}-${col.key}`} - className={`p-2 text-center whitespace-nowrap border border-gray-200 ${ - isFirstInGroup ? 'border-l border-l-gray-200' : '' - } ${ - isLastInGroup ? 'border-r border-r-gray-200' : '' - }`} - style={{ backgroundColor: 'white' }} - > - {col.label} - </th> - ); - }); - })} - </tr> - </thead> - - {/* 테이블 바디 */} - <tbody> - {allItemIds.map((itemId) => { - // 자재 기본 정보는 첫 번째 벤더 아이템 기준 - const firstQid = quotations[0]?.id - const sampleItem = firstQid - ? findItemByQuotationId(itemId, firstQid) - : undefined - - return ( - <tr key={itemId} className="border-b border-gray-100"> - {/* 자재 (코드) 셀 */} - <td - className="sticky left-0 z-10 p-2 align-top border-r border-gray-100" - style={{ - width: '250px', - minWidth: '250px', - backgroundColor: 'white', - }} - > - {sampleItem?.materialDescription || sampleItem?.materialCode || ""} - {sampleItem && ( - <div className="text-xs text-muted-foreground mt-1"> - 코드: {sampleItem.materialCode} | 수량:{" "} - {sampleItem.quantity} {sampleItem.uom} - </div> - )} - </td> - - {/* 벤더별 아이템 데이터 */} - {quotations.flatMap((q, qIndex) => { - const collapsed = collapsedVendors[q.id] - const itemData = findItemByQuotationId(itemId, q.id) - - // 접힌 상태면 총액만 표시 - if (collapsed) { - return [ - <td - key={`${q.id}-collapsed`} - className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100" - > - {itemData - ? formatCurrency(Number(itemData.totalPrice), itemData.currency) - : "N/A"} - </td> - ]; - } - - // 펼친 상태 - 아이템 없음 - if (!itemData) { - return [ - <td - key={`${q.id}-empty`} - colSpan={6} - className="p-2 text-center text-sm border-r border-gray-100" - > - 없음 - </td> - ]; - } - - // 펼친 상태 - 모든 컬럼 표시 - const columns = [ - { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' }, - { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true }, - { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' }, - { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' }, - { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' }, - { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' }, - ]; - - return columns.map((col, colIndex) => { - const isFirstInGroup = colIndex === 0; - const isLastInGroup = colIndex === columns.length - 1; - - return ( - <td - key={`${q.id}-${col.key}`} - className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${ - isFirstInGroup ? 'border-l border-l-gray-100' : '' - } ${ - isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100' - }`} - > - {col.render()} - </td> - ); - }); - })} - </tr> - ); - })} - - {/* 아이템이 전혀 없는 경우 */} - {allItemIds.length === 0 && ( - <tr> - <td - colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버 - className="text-center p-4 border border-gray-100" - > - 아이템 정보가 없습니다 - </td> - </tr> - )} - </tbody> - </table> - </div> - </div> - </TabsContent> - </Tabs> - )} - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 닫기 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/procurement-rfqs/table/pr-item-dialog.tsx b/lib/procurement-rfqs/table/pr-item-dialog.tsx deleted file mode 100644 index aada8438..00000000 --- a/lib/procurement-rfqs/table/pr-item-dialog.tsx +++ /dev/null @@ -1,258 +0,0 @@ -"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, "KR") : "-"} - </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-filter-sheet.tsx b/lib/procurement-rfqs/table/rfq-filter-sheet.tsx deleted file mode 100644 index a746603b..00000000 --- a/lib/procurement-rfqs/table/rfq-filter-sheet.tsx +++ /dev/null @@ -1,686 +0,0 @@ -"use client" - -import { useEffect, useTransition, useState, useRef } from "react" -import { useRouter, useParams } from "next/navigation" -import { z } from "zod" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { CalendarIcon, ChevronRight, Search, X } from "lucide-react" -import { customAlphabet } from "nanoid" -import { parseAsStringEnum, useQueryState } from "nuqs" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { cn } from "@/lib/utils" -import { useTranslation } from '@/i18n/client' -import { getFiltersStateParser } from "@/lib/parsers" -import { DateRangePicker } from "@/components/date-range-picker" - -// nanoid 생성기 -const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) - -// 필터 스키마 정의 (RFQ 관련 항목 유지) -const filterSchema = z.object({ - picCode: z.string().optional(), - projectCode: z.string().optional(), - rfqCode: z.string().optional(), - itemCode: z.string().optional(), - majorItemMaterialCode: z.string().optional(), - status: z.string().optional(), - dateRange: z.object({ - from: z.date().optional(), - to: z.date().optional(), - }).optional(), -}) - -// 상태 옵션 정의 -const statusOptions = [ - { value: "RFQ Created", label: "RFQ Created" }, - { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" }, - { value: "RFQ Sent", label: "RFQ Sent" }, - { value: "Quotation Analysis", label: "Quotation Analysis" }, - { value: "PO Transfer", label: "PO Transfer" }, - { value: "PO Create", label: "PO Create" }, -] - -type FilterFormValues = z.infer<typeof filterSchema> - -interface RFQFilterSheetProps { - isOpen: boolean; - onClose: () => void; - onSearch?: () => void; - isLoading?: boolean; -} - -// Updated component for inline use (not a sheet anymore) -export function RFQFilterSheet({ - isOpen, - onClose, - onSearch, - isLoading = false -}: RFQFilterSheetProps) { - const router = useRouter() - const params = useParams(); - const lng = params ? (params.lng as string) : 'ko'; - const { t } = useTranslation(lng); - - const [isPending, startTransition] = useTransition() - - // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 - const [isInitializing, setIsInitializing] = useState(false) - // 마지막으로 적용된 필터를 추적하기 위한 ref - const lastAppliedFilters = useRef<string>("") - - // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경 - const [filters, setFilters] = useQueryState( - "basicFilters", - getFiltersStateParser().withDefault([]) - ) - - // joinOperator 설정 - const [joinOperator, setJoinOperator] = useQueryState( - "basicJoinOperator", - parseAsStringEnum(["and", "or"]).withDefault("and") - ) - - // 현재 URL의 페이지 파라미터도 가져옴 - const [page, setPage] = useQueryState("page", { defaultValue: "1" }) - - // 폼 상태 초기화 - const form = useForm<FilterFormValues>({ - resolver: zodResolver(filterSchema), - defaultValues: { - picCode: "", - projectCode: "", - rfqCode: "", - itemCode: "", - majorItemMaterialCode: "", - status: "", - dateRange: { - from: undefined, - to: undefined, - }, - }, - }) - - // URL 필터에서 초기 폼 상태 설정 - 개선된 버전 - useEffect(() => { - // 현재 필터를 문자열로 직렬화 - const currentFiltersString = JSON.stringify(filters); - - // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 - if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { - setIsInitializing(true); - - const formValues = { ...form.getValues() }; - let formUpdated = false; - - filters.forEach(filter => { - if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) { - formValues.dateRange = { - from: filter.value[0] ? new Date(filter.value[0]) : undefined, - to: filter.value[1] ? new Date(filter.value[1]) : undefined, - }; - formUpdated = true; - } else if (filter.id in formValues) { - // @ts-ignore - 동적 필드 접근 - formValues[filter.id] = filter.value; - formUpdated = true; - } - }); - - // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 - if (formUpdated) { - form.reset(formValues); - lastAppliedFilters.current = currentFiltersString; - } - - setIsInitializing(false); - } - }, [filters, isOpen]) // form 의존성 제거 - - // 현재 적용된 필터 카운트 - const getActiveFilterCount = () => { - return filters?.length || 0 - } - - // 폼 제출 핸들러 - PQ 방식으로 수정 (수동 URL 업데이트 버전) - async function onSubmit(data: FilterFormValues) { - // 초기화 중이면 제출 방지 - if (isInitializing) return; - - startTransition(async () => { - try { - // 필터 배열 생성 - const newFilters = [] - - if (data.picCode?.trim()) { - newFilters.push({ - id: "picCode", - value: data.picCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.projectCode?.trim()) { - newFilters.push({ - id: "projectCode", - value: data.projectCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.rfqCode?.trim()) { - newFilters.push({ - id: "rfqCode", - value: data.rfqCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.itemCode?.trim()) { - newFilters.push({ - id: "itemCode", - value: data.itemCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.majorItemMaterialCode?.trim()) { - newFilters.push({ - id: "majorItemMaterialCode", - value: data.majorItemMaterialCode.trim(), - type: "text", - operator: "iLike", - rowId: generateId() - }) - } - - if (data.status?.trim()) { - newFilters.push({ - id: "status", - value: data.status.trim(), - type: "select", - operator: "eq", - rowId: generateId() - }) - } - - // Add date range to params if it exists - if (data.dateRange?.from) { - newFilters.push({ - id: "rfqSendDate", - value: [ - data.dateRange.from.toISOString().split('T')[0], - data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined - ].filter(Boolean), - type: "date", - operator: "isBetween", - rowId: generateId() - }) - } - - console.log("=== RFQ Filter Submit Debug ==="); - console.log("Generated filters:", newFilters); - console.log("Join operator:", joinOperator); - - // 🔑 PQ 방식: 수동으로 URL 업데이트 (nuqs 대신) - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - // 기존 필터 관련 파라미터 제거 - params.delete('basicFilters'); - params.delete('basicJoinOperator'); - params.delete('page'); - - // 새로운 필터 추가 - if (newFilters.length > 0) { - params.set('basicFilters', JSON.stringify(newFilters)); - params.set('basicJoinOperator', joinOperator); - } - - // 페이지를 1로 설정 - params.set('page', '1'); - - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - console.log("New URL:", newUrl); - - // 🔑 PQ 방식: 페이지 완전 새로고침으로 서버 렌더링 강제 - window.location.href = newUrl; - - // 마지막 적용된 필터 업데이트 - lastAppliedFilters.current = JSON.stringify(newFilters); - - // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) - if (onSearch) { - console.log("Calling onSearch..."); - onSearch(); - } - - console.log("=== RFQ Filter Submit Complete ==="); - } catch (error) { - console.error("RFQ 필터 적용 오류:", error); - } - }) - } - - // 필터 초기화 핸들러 - PQ 방식으로 수정 - async function handleReset() { - try { - setIsInitializing(true); - - form.reset({ - picCode: "", - projectCode: "", - rfqCode: "", - itemCode: "", - majorItemMaterialCode: "", - status: "", - dateRange: { from: undefined, to: undefined }, - }); - - console.log("=== RFQ Filter Reset Debug ==="); - console.log("Current URL before reset:", window.location.href); - - // 🔑 PQ 방식: 수동으로 URL 초기화 - const currentUrl = new URL(window.location.href); - const params = new URLSearchParams(currentUrl.search); - - // 필터 관련 파라미터 제거 - params.delete('basicFilters'); - params.delete('basicJoinOperator'); - params.set('page', '1'); - - const newUrl = `${currentUrl.pathname}?${params.toString()}`; - console.log("Reset URL:", newUrl); - - // 🔑 PQ 방식: 페이지 완전 새로고침 - window.location.href = newUrl; - - // 마지막 적용된 필터 초기화 - lastAppliedFilters.current = ""; - - console.log("RFQ 필터 초기화 완료"); - setIsInitializing(false); - } catch (error) { - console.error("RFQ 필터 초기화 오류:", error); - setIsInitializing(false); - } - } - - // Don't render if not open (for side panel use) - if (!isOpen) { - return null; - } - - return ( - <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> - {/* Filter Panel Header */} - <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> - <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3> - <div className="flex items-center gap-2"> - {getActiveFilterCount() > 0 && ( - <Badge variant="secondary" className="px-2 py-1"> - {getActiveFilterCount()}개 필터 적용됨 - </Badge> - )} - </div> - </div> - - {/* Join Operator Selection */} - <div className="px-6 shrink-0"> - <label className="text-sm font-medium">조건 결합 방식</label> - <Select - value={joinOperator} - onValueChange={(value: "and" | "or") => setJoinOperator(value)} - disabled={isInitializing} - > - <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> - <SelectValue placeholder="조건 결합 방식" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> - <SelectItem value="or">하나라도 충족 (OR)</SelectItem> - </SelectContent> - </Select> - </div> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> - {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */} - <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> - <div className="space-y-4 pt-2"> - {/* 발주 담당 */} - <FormField - control={form.control} - name="picCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("발주담당")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("발주담당 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("picCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 프로젝트 코드 */} - <FormField - control={form.control} - name="projectCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("프로젝트 코드")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("프로젝트 코드 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("projectCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* RFQ NO. */} - <FormField - control={form.control} - name="rfqCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("RFQ NO.")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("RFQ 번호 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("rfqCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 자재그룹 */} - <FormField - control={form.control} - name="itemCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("자재그룹")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("자재그룹 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("itemCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 자재코드 */} - <FormField - control={form.control} - name="majorItemMaterialCode" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("자재코드")}</FormLabel> - <FormControl> - <div className="relative"> - <Input - placeholder={t("자재코드 입력")} - {...field} - className={cn(field.value && "pr-8", "bg-white")} - disabled={isInitializing} - /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-0 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("majorItemMaterialCode", ""); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* Status */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("Status")}</FormLabel> - <Select - value={field.value} - onValueChange={field.onChange} - disabled={isInitializing} - > - <FormControl> - <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> - <div className="flex justify-between w-full"> - <SelectValue placeholder={t("Select status")} /> - {field.value && ( - <Button - type="button" - variant="ghost" - size="icon" - className="h-4 w-4 -mr-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("status", ""); - }} - disabled={isInitializing} - > - <X className="size-3" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </SelectTrigger> - </FormControl> - <SelectContent> - {statusOptions.map(option => ( - <SelectItem key={option.value} value={option.value}> - {option.label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - {/* RFQ 전송일 */} - <FormField - control={form.control} - name="dateRange" - render={({ field }) => ( - <FormItem> - <FormLabel>{t("RFQ 전송일")}</FormLabel> - <FormControl> - <div className="relative"> - <DateRangePicker - triggerSize="default" - triggerClassName="w-full bg-white" - align="start" - showClearButton={true} - placeholder={t("RFQ 전송일 범위를 고르세요")} - value={field.value || undefined} - onChange={field.onChange} - disabled={isInitializing} - /> - {(field.value?.from || field.value?.to) && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-10 top-0 h-full px-2" - onClick={(e) => { - e.stopPropagation(); - form.setValue("dateRange", { from: undefined, to: undefined }); - }} - disabled={isInitializing} - > - <X className="size-3.5" /> - <span className="sr-only">Clear</span> - </Button> - )} - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </div> - - {/* Fixed buttons at bottom */} - <div className="p-4 shrink-0"> - <div className="flex gap-2 justify-end"> - <Button - type="button" - variant="outline" - onClick={handleReset} - disabled={isPending || getActiveFilterCount() === 0 || isInitializing} - className="px-4" - > - {t("초기화")} - </Button> - <Button - type="submit" - variant="samsung" - disabled={isPending || isLoading || isInitializing} - className="px-4" - > - <Search className="size-4 mr-2" /> - {isPending || isLoading ? t("조회 중...") : t("조회")} - </Button> - </div> - </div> - </form> - </Form> - </div> - ) -}
\ 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 deleted file mode 100644 index 3cf06315..00000000 --- a/lib/procurement-rfqs/table/rfq-table-column.tsx +++ /dev/null @@ -1,373 +0,0 @@ -"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 deleted file mode 100644 index 26725797..00000000 --- a/lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx +++ /dev/null @@ -1,279 +0,0 @@ -"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 deleted file mode 100644 index ca976172..00000000 --- a/lib/procurement-rfqs/table/rfq-table.tsx +++ /dev/null @@ -1,412 +0,0 @@ -"use client" - -import * as React from "react" -import { useSearchParams } from "next/navigation" -import { Button } from "@/components/ui/button" -import { PanelLeftClose, PanelLeftOpen } from "lucide-react" -import type { - DataTableAdvancedFilterField, - DataTableRowAction, -} from "@/types/table" -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, -} from "@/components/ui/resizable" - -import { useDataTable } from "@/hooks/use-data-table" -import { DataTable } from "@/components/data-table/data-table" -import { getColumns, EditingCellState } from "./rfq-table-column" -import { useEffect, useCallback, useRef, useMemo, useLayoutEffect } 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" -import { useTablePresets } from "@/components/data-table/use-table-presets" -import { TablePresetManager } from "@/components/data-table/data-table-preset" -import { Loader2 } from "lucide-react" -import { RFQFilterSheet } from "./rfq-filter-sheet" -import { RfqDetailTables } from "./detail-table/rfq-detail-table" -import { cn } from "@/lib/utils" - -interface RFQListTableProps { - promises: Promise<[Awaited<ReturnType<typeof getPORfqs>>]> - className?: string; - calculatedHeight?: string; // 계산된 높이 추가 -} - -export function RFQListTable({ - promises, - className, - calculatedHeight -}: RFQListTableProps) { - const searchParams = useSearchParams() - - // 필터 패널 상태 - const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) - - // 선택된 RFQ 상태 - const [selectedRfq, setSelectedRfq] = React.useState<ProcurementRfqsView | null>(null) - - // 패널 collapse 상태 - const [isTopCollapsed, setIsTopCollapsed] = React.useState(false) - const [panelHeight, setPanelHeight] = React.useState<number>(55) - - // refs - const headerRef = React.useRef<HTMLDivElement>(null) - - // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요) - const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이 - const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값) - const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border) - const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비 - - // 높이 계산 - // 필터 패널 높이 - Layout Header와 Footer 사이 - const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)` - - console.log(calculatedHeight) - - // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외 - const FIXED_TABLE_HEIGHT = calculatedHeight - ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)` - : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback - - // Suspense 방식으로 데이터 처리 - const [promiseData] = React.use(promises) - const tableData = promiseData - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null) - const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null) - - // 초기 설정 정의 - const initialSettings = React.useMemo(() => ({ - page: parseInt(searchParams.get('page') || '1'), - perPage: parseInt(searchParams.get('perPage') || '10'), - sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }], - filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], - joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", - basicFilters: searchParams.get('basicFilters') ? JSON.parse(searchParams.get('basicFilters')!) : [], - basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", - search: searchParams.get('search') || '', - from: searchParams.get('from') || undefined, - to: searchParams.get('to') || undefined, - columnVisibility: {}, - columnOrder: [], - pinnedColumns: { left: [], right: [] }, - groupBy: [], - expandedRows: [] - }), [searchParams]) - - // DB 기반 프리셋 훅 사용 - const { - presets, - activePresetId, - hasUnsavedChanges, - isLoading: presetsLoading, - createPreset, - applyPreset, - updatePreset, - deletePreset, - setDefaultPreset, - renamePreset, - getCurrentSettings, - } = useTablePresets<ProcurementRfqsView>('rfq-list-table', initialSettings) - - // 비고 업데이트 함수 - const updateRemark = async (rfqId: number, remark: string) => { - try { - const result = await updateRfqRemark(rfqId, remark); - - if (result.success) { - toast.success("비고가 업데이트되었습니다"); - } else { - toast.error(result.message || "업데이트 중 오류가 발생했습니다"); - } - } catch (error) { - console.error("비고 업데이트 오류:", error); - toast.error("업데이트 중 오류가 발생했습니다"); - } - } - - // 행 액션 처리 - useEffect(() => { - if (rowAction) { - switch (rowAction.type) { - case "select": - setSelectedRfq(rowAction.row.original) - break; - case "update": - console.log("Update rfq:", rowAction.row.original) - break; - case "delete": - console.log("Delete rfq:", rowAction.row.original) - break; - } - setRowAction(null) - } - }, [rowAction]) - - const columns = React.useMemo( - () => getColumns({ - setRowAction, - editingCell, - setEditingCell, - updateRemark - }), - [setRowAction, editingCell, setEditingCell, updateRemark] - ) - - // 고급 필터 필드 정의 - 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", - }, - ] - - // 현재 설정 가져오기 - const currentSettings = useMemo(() => { - return getCurrentSettings() - }, [getCurrentSettings]) - - // useDataTable 초기 상태 설정 - const initialState = useMemo(() => { - return { - sorting: initialSettings.sort.filter(sortItem => { - const columnExists = columns.some(col => col.accessorKey === sortItem.id) - return columnExists - }) as any, - columnVisibility: currentSettings.columnVisibility, - columnPinning: currentSettings.pinnedColumns, - } - }, [currentSettings, initialSettings.sort, columns]) - - // useDataTable 훅 설정 (PQ와 동일한 설정) - const { table } = useDataTable({ - data: tableData?.data || [], - columns, - pageCount: tableData?.pageCount || 0, - rowCount: tableData?.total || 0, - filterFields: [], // PQ와 동일하게 빈 배열 - enablePinning: true, - enableAdvancedFilter: true, - initialState, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, // PQ와 동일하게 false - clearOnDefault: true, - }) - - // 조회 버튼 클릭 핸들러 - const handleSearch = () => { - setIsFilterPanelOpen(false) - } - - // Get active basic filter count (PQ와 동일한 방식) - const getActiveBasicFilterCount = () => { - try { - const basicFilters = searchParams.get('basicFilters') - return basicFilters ? JSON.parse(basicFilters).length : 0 - } catch (e) { - return 0 - } - } - - console.log(panelHeight) - - return ( - <div - className={cn("flex flex-col relative", className)} - style={{ height: calculatedHeight }} - > - {/* Filter Panel - 계산된 높이 적용 */} - <div - className={cn( - "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", - isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" - )} - style={{ - width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', - top: `${LAYOUT_HEADER_HEIGHT*2}px`, - height: FIXED_FILTER_HEIGHT - }} - > - {/* Filter Content */} - <div className="h-full"> - <RFQFilterSheet - isOpen={isFilterPanelOpen} - onClose={() => setIsFilterPanelOpen(false)} - onSearch={handleSearch} - isLoading={false} - /> - </div> - </div> - - {/* Main Content */} - <div - className="flex flex-col transition-all duration-300 ease-in-out" - style={{ - width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', - marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', - height: '100%' - }} - > - {/* Header Bar - 고정 높이 */} - <div - ref={headerRef} - className="flex items-center justify-between p-4 bg-background border-b" - style={{ - height: `${LOCAL_HEADER_HEIGHT}px`, - flexShrink: 0 - }} - > - <div className="flex items-center gap-3"> - <Button - variant="outline" - size="sm" - type='button' - onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} - className="flex items-center shadow-sm" - > - {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} - {getActiveBasicFilterCount() > 0 && ( - <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> - {getActiveBasicFilterCount()} - </span> - )} - </Button> - </div> - - {/* Right side info */} - <div className="text-sm text-muted-foreground"> - {tableData && ( - <span>총 {tableData.total || 0}건</span> - )} - </div> - </div> - - {/* Table Content Area - 계산된 높이 사용 */} - <div - className="relative bg-background" - style={{ - height: FIXED_TABLE_HEIGHT, - display: 'grid', - gridTemplateRows: '1fr', - gridTemplateColumns: '1fr' - }} - > - <ResizablePanelGroup - direction="vertical" - className="w-full h-full" - > - <ResizablePanel - defaultSize={60} - minSize={25} - maxSize={75} - collapsible={false} - onResize={(size) => { - setPanelHeight(size) - }} - className="flex flex-col overflow-hidden" - > - {/* 상단 테이블 영역 */} - <div className="flex-1 min-h-0 overflow-hidden"> - <DataTable - table={table} - // className="h-full" - maxHeight={`${panelHeight*0.5}vh`} - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <div className="flex items-center gap-2"> - <TablePresetManager<ProcurementRfqsView> - presets={presets} - activePresetId={activePresetId} - currentSettings={currentSettings} - hasUnsavedChanges={hasUnsavedChanges} - isLoading={presetsLoading} - onCreatePreset={createPreset} - onUpdatePreset={updatePreset} - onDeletePreset={deletePreset} - onApplyPreset={applyPreset} - onSetDefaultPreset={setDefaultPreset} - onRenamePreset={renamePreset} - /> - - <RFQTableToolbarActions - table={table} - localData={tableData} - setLocalData={() => {}} - onSuccess={() => {}} - /> - </div> - </DataTableAdvancedToolbar> - </DataTable> - </div> - </ResizablePanel> - - <ResizableHandle withHandle /> - - <ResizablePanel - minSize={25} - defaultSize={40} - collapsible={false} - className="flex flex-col overflow-hidden" - > - {/* 하단 상세 테이블 영역 */} - <div className="flex-1 min-h-0 overflow-hidden bg-background"> - <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/> - </div> - </ResizablePanel> - </ResizablePanelGroup> - </div> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/validations.ts b/lib/procurement-rfqs/validations.ts deleted file mode 100644 index 5059755f..00000000 --- a/lib/procurement-rfqs/validations.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index 69ba0363..00000000 --- a/lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx +++ /dev/null @@ -1,522 +0,0 @@ -"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 deleted file mode 100644 index 66bb2613..00000000 --- a/lib/procurement-rfqs/vendor-response/quotation-editor.tsx +++ /dev/null @@ -1,955 +0,0 @@ -"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) { - - - console.log(quotation) - - 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 deleted file mode 100644 index e11864dc..00000000 --- a/lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx +++ /dev/null @@ -1,664 +0,0 @@ -"use client" - -import * as React from "react" -import { useState, useEffect, useRef } from "react" -import { toast } from "sonner" -import { format } from "date-fns" - -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Checkbox } from "@/components/ui/checkbox" -import { DatePicker } from "@/components/ui/date-picker" -import { - Table, - TableBody, - TableCaption, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@/components/ui/table" -import { Badge } from "@/components/ui/badge" -import { ScrollArea } from "@/components/ui/scroll-area" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from "@/components/ui/tooltip" -import { - Info, - Clock, - CalendarIcon, - ClipboardCheck, - AlertTriangle, - CheckCircle2, - RefreshCw, - Save, - FileText, - Sparkles -} from "lucide-react" - -import { formatCurrency } from "@/lib/utils" -import { updateQuotationItem } from "../services" -import { Textarea } from "@/components/ui/textarea" - -// 견적 아이템 타입 -interface QuotationItem { - id: number - quotationId: number - prItemId: number - materialCode: string | null - materialDescription: string | null - quantity: number - uom: string | null - unitPrice: number - totalPrice: number - currency: string - vendorMaterialCode: string | null - vendorMaterialDescription: string | null - deliveryDate: Date | null - leadTimeInDays: number | null - taxRate: number | null - taxAmount: number | null - discountRate: number | null - discountAmount: number | null - remark: string | null - isAlternative: boolean - isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음 - createdAt: Date - updatedAt: Date - prItem?: { - id: number - materialCode: string | null - materialDescription: string | null - // 기타 필요한 정보 - } -} - -// debounce 함수 구현 -function debounce<T extends (...args: any[]) => any>( - func: T, - wait: number -): (...args: Parameters<T>) => void { - let timeout: NodeJS.Timeout | null = null; - - return function (...args: Parameters<T>) { - if (timeout) clearTimeout(timeout); - timeout = setTimeout(() => func(...args), wait); - }; -} - -interface QuotationItemEditorProps { - items: QuotationItem[] - onItemsChange: (items: QuotationItem[]) => void - disabled?: boolean - currency: string -} - -export function QuotationItemEditor({ - items, - onItemsChange, - disabled = false, - currency -}: QuotationItemEditorProps) { - const [editingItem, setEditingItem] = useState<number | null>(null) - const [isSaving, setIsSaving] = useState(false) - - // 저장이 필요한 항목들을 추적 - const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set()) - - // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음 - const updateLocalItem = <K extends keyof QuotationItem>( - index: number, - field: K, - value: QuotationItem[K] - ) => { - // 로컬 상태 업데이트 - const updatedItems = [...items] - const item = { ...updatedItems[index] } - - // 필드 업데이트 - item[field] = value - - // 대체품 체크 해제 시 관련 필드 초기화 - if (field === 'isAlternative' && value === false) { - item.vendorMaterialCode = null; - item.vendorMaterialDescription = null; - item.remark = null; - } - - // 단가나 수량이 변경되면 총액 계산 - if (field === 'unitPrice' || field === 'quantity') { - item.totalPrice = Number(item.unitPrice) * Number(item.quantity) - - // 세금이 있으면 세액 계산 - if (item.taxRate) { - item.taxAmount = item.totalPrice * (item.taxRate / 100) - } - - // 할인이 있으면 할인액 계산 - if (item.discountRate) { - item.discountAmount = item.totalPrice * (item.discountRate / 100) - } - } - - // 세율이 변경되면 세액 계산 - if (field === 'taxRate') { - item.taxAmount = item.totalPrice * (value as number / 100) - } - - // 할인율이 변경되면 할인액 계산 - if (field === 'discountRate') { - item.discountAmount = item.totalPrice * (value as number / 100) - } - - // 변경된 아이템으로 교체 - updatedItems[index] = item - - // 미저장 항목으로 표시 - setPendingChanges(prev => new Set(prev).add(item.id)) - - // 부모 컴포넌트에 변경 사항 알림 - onItemsChange(updatedItems) - - // 저장 필요함을 표시 - return item - } - - // 서버에 저장하는 함수 - const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => { - if (disabled) return - - try { - setIsSaving(true) - - const result = await updateQuotationItem({ - id: item.id, - [field]: value, - totalPrice: item.totalPrice, - taxAmount: item.taxAmount ?? 0, - discountAmount: item.discountAmount ?? 0 - }) - - // 저장 완료 후 pendingChanges에서 제거 - setPendingChanges(prev => { - const newSet = new Set(prev) - newSet.delete(item.id) - return newSet - }) - - if (!result.success) { - toast.error(result.message || "항목 저장 중 오류가 발생했습니다") - } - } catch (error) { - console.error("항목 저장 오류:", error) - toast.error("항목 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // debounce된 저장 함수 - const debouncedSave = useRef(debounce( - (item: QuotationItem, field: keyof QuotationItem, value: any) => { - saveItemToServer(item, field, value) - }, - 800 // 800ms 지연 - )).current - - // 견적 항목 업데이트 함수 - const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => { - const updatedItem = updateLocalItem(index, field, value) - - // debounce를 통해 서버 저장 지연 - if (!disabled) { - debouncedSave(updatedItem, field, value) - } - } - - // 모든 변경 사항 저장 - const saveAllChanges = async () => { - if (disabled || pendingChanges.size === 0) return - - setIsSaving(true) - toast.info(`${pendingChanges.size}개 항목 저장 중...`) - - try { - // 변경된 모든 항목 저장 - for (const itemId of pendingChanges) { - const index = items.findIndex(item => item.id === itemId) - if (index !== -1) { - const item = items[index] - await updateQuotationItem({ - id: item.id, - unitPrice: item.unitPrice, - totalPrice: item.totalPrice, - taxRate: item.taxRate ?? 0, - taxAmount: item.taxAmount ?? 0, - discountRate: item.discountRate ?? 0, - discountAmount: item.discountAmount ?? 0, - deliveryDate: item.deliveryDate, - leadTimeInDays: item.leadTimeInDays ?? 0, - vendorMaterialCode: item.vendorMaterialCode ?? "", - vendorMaterialDescription: item.vendorMaterialDescription ?? "", - isAlternative: item.isAlternative, - isRecommended: false, // 항상 false로 설정 (사용하지 않음) - remark: item.remark ?? "" - }) - } - } - - // 모든 변경 사항 저장 완료 - setPendingChanges(new Set()) - toast.success("모든 변경 사항이 저장되었습니다") - } catch (error) { - console.error("변경 사항 저장 오류:", error) - toast.error("변경 사항 저장 중 오류가 발생했습니다") - } finally { - setIsSaving(false) - } - } - - // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후) - const handleBlur = (index: number, field: keyof QuotationItem, value: any) => { - const itemId = items[index].id - - // 해당 항목이 pendingChanges에 있다면 즉시 저장 - if (pendingChanges.has(itemId)) { - const item = items[index] - saveItemToServer(item, field, value) - } - } - - // 전체 단가 업데이트 (일괄 반영) - const handleBulkUnitPriceUpdate = () => { - if (items.length === 0) return - - // 첫 번째 아이템의 단가 가져오기 - const firstUnitPrice = items[0].unitPrice - - if (!firstUnitPrice) { - toast.error("첫 번째 항목의 단가를 먼저 입력해주세요") - return - } - - // 모든 아이템에 동일한 단가 적용 - const updatedItems = items.map(item => ({ - ...item, - unitPrice: firstUnitPrice, - totalPrice: firstUnitPrice * item.quantity, - taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount, - discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount - })) - - // 모든 아이템을 변경 필요 항목으로 표시 - setPendingChanges(new Set(updatedItems.map(item => item.id))) - - // 부모 컴포넌트에 변경 사항 알림 - onItemsChange(updatedItems) - - toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.") - } - - // 입력 핸들러 - const handleNumberInputChange = ( - index: number, - field: keyof QuotationItem, - e: React.ChangeEvent<HTMLInputElement> - ) => { - const value = e.target.value === '' ? 0 : parseFloat(e.target.value) - handleItemUpdate(index, field, value) - } - - const handleTextInputChange = ( - index: number, - field: keyof QuotationItem, - e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> - ) => { - handleItemUpdate(index, field, e.target.value) - } - - const handleDateChange = ( - index: number, - field: keyof QuotationItem, - date: Date | undefined - ) => { - handleItemUpdate(index, field, date || null) - } - - const handleCheckboxChange = ( - index: number, - field: keyof QuotationItem, - checked: boolean - ) => { - handleItemUpdate(index, field, checked) - } - - // 날짜 형식 지정 - const formatDeliveryDate = (date: Date | null) => { - if (!date) return "-" - return format(date, "yyyy-MM-dd") - } - - // 입력 폼 필드 렌더링 - const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => { - if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') { - return ( - <Input - type="number" - min={0} - step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1} - value={item[field] as number || 0} - onChange={(e) => handleNumberInputChange(index, field, e)} - onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)} - disabled={disabled || isSaving} - className="w-full" - /> - ) - } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') { - return ( - <Input - type="text" - value={item[field] as string || ''} - onChange={(e) => handleTextInputChange(index, field, e)} - onBlur={(e) => handleBlur(index, field, e.target.value)} - disabled={disabled || isSaving || !item.isAlternative} - className="w-full" - placeholder={field === 'vendorMaterialCode' ? "벤더 자재코드" : "벤더 자재명"} - /> - ) - } else if (field === 'deliveryDate') { - return ( - <DatePicker - date={item.deliveryDate ? new Date(item.deliveryDate) : undefined} - onSelect={(date) => { - handleDateChange(index, field, date); - // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거 - if (date) handleBlur(index, field, date); - }} - disabled={disabled || isSaving} - /> - ) - } else if (field === 'isAlternative') { - return ( - <div className="flex items-center gap-1"> - <Checkbox - checked={item.isAlternative} - onCheckedChange={(checked) => { - handleCheckboxChange(index, field, checked as boolean); - handleBlur(index, field, checked as boolean); - }} - disabled={disabled || isSaving} - /> - <span className="text-xs">대체품</span> - </div> - ) - } - - return null - } - - // 대체품 필드 렌더링 - const renderAlternativeFields = (item: QuotationItem, index: number) => { - if (!item.isAlternative) return null; - - return ( - <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm"> - {/* <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">벤더 자재코드</label> - <Input - value={item.vendorMaterialCode || ""} - onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)} - onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)} - disabled={disabled || isSaving} - className="h-8 text-sm" - placeholder="벤더 자재코드 입력" - /> - </div> */} - - <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">벤더 자재명</label> - <Input - value={item.vendorMaterialDescription || ""} - onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)} - onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)} - disabled={disabled || isSaving} - className="h-8 text-sm" - placeholder="벤더 자재명 입력" - /> - </div> - - <div className="flex flex-col gap-2"> - <label className="text-xs font-medium text-blue-700">대체품 설명</label> - <Textarea - value={item.remark || ""} - onChange={(e) => handleTextInputChange(index, 'remark', e)} - onBlur={(e) => handleBlur(index, 'remark', e.target.value)} - disabled={disabled || isSaving} - className="min-h-[60px] text-sm" - placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요" - /> - </div> - </div> - ); - }; - - // 항목의 저장 상태 아이콘 표시 - const renderSaveStatus = (itemId: number) => { - if (pendingChanges.has(itemId)) { - return ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" /> - </TooltipTrigger> - <TooltipContent> - <p>저장되지 않은 변경 사항이 있습니다</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - ) - } - - return null - } - - return ( - <div className="space-y-4"> - <div className="flex justify-between items-center"> - <div className="flex items-center gap-2"> - <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3> - {pendingChanges.size > 0 && ( - <Badge variant="outline" className="bg-yellow-50"> - 변경 {pendingChanges.size}개 - </Badge> - )} - </div> - - <div className="flex items-center gap-2"> - {pendingChanges.size > 0 && !disabled && ( - <Button - variant="default" - size="sm" - onClick={saveAllChanges} - disabled={isSaving} - > - {isSaving ? ( - <RefreshCw className="h-4 w-4 mr-2 animate-spin" /> - ) : ( - <Save className="h-4 w-4 mr-2" /> - )} - 변경사항 저장 ({pendingChanges.size}개) - </Button> - )} - - {!disabled && ( - <Button - variant="outline" - size="sm" - onClick={handleBulkUnitPriceUpdate} - disabled={items.length === 0 || isSaving} - > - 첫 항목 단가로 일괄 적용 - </Button> - )} - </div> - </div> - - <ScrollArea className="h-[500px] rounded-md border"> - <Table> - <TableHeader className="sticky top-0 bg-background"> - <TableRow> - <TableHead className="w-[50px]">번호</TableHead> - <TableHead>자재코드</TableHead> - <TableHead>자재명</TableHead> - <TableHead>수량</TableHead> - <TableHead>단위</TableHead> - <TableHead>단가</TableHead> - <TableHead>금액</TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 세율(%) - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 납품일 - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>납품 가능한 날짜를 선택해주세요.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead>리드타임(일)</TableHead> - <TableHead> - <div className="flex items-center gap-1"> - 대체품 - <TooltipProvider> - <Tooltip> - <TooltipTrigger> - <Info className="h-4 w-4" /> - </TooltipTrigger> - <TooltipContent> - <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p> - <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p> - </TooltipContent> - </Tooltip> - </TooltipProvider> - </div> - </TableHead> - <TableHead className="w-[50px]">상태</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {items.length === 0 ? ( - <TableRow> - <TableCell colSpan={12} className="text-center py-10"> - 견적 항목이 없습니다 - </TableCell> - </TableRow> - ) : ( - items.map((item, index) => ( - <React.Fragment key={item.id}> - <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}> - <TableCell> - {index + 1} - </TableCell> - <TableCell> - {item.materialCode || "-"} - </TableCell> - <TableCell> - <div className="font-medium max-w-xs truncate"> - {item.materialDescription || "-"} - </div> - </TableCell> - <TableCell> - {item.quantity} - </TableCell> - <TableCell> - {item.uom || "-"} - </TableCell> - <TableCell> - {renderInputField(item, index, 'unitPrice')} - </TableCell> - <TableCell> - {formatCurrency(item.totalPrice, currency)} - </TableCell> - <TableCell> - {renderInputField(item, index, 'taxRate')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'deliveryDate')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'leadTimeInDays')} - </TableCell> - <TableCell> - {renderInputField(item, index, 'isAlternative')} - </TableCell> - <TableCell> - {renderSaveStatus(item.id)} - </TableCell> - </TableRow> - - {/* 대체품으로 선택된 경우 추가 정보 행 표시 */} - {item.isAlternative && ( - <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}> - <TableCell colSpan={1}></TableCell> - <TableCell colSpan={10}> - {renderAlternativeFields(item, index)} - </TableCell> - <TableCell colSpan={1}></TableCell> - </TableRow> - )} - </React.Fragment> - )) - )} - </TableBody> - </Table> - </ScrollArea> - - {isSaving && ( - <div className="flex items-center justify-center text-sm text-muted-foreground"> - <Clock className="h-4 w-4 animate-spin mr-2" /> - 변경 사항을 저장 중입니다... - </div> - )} - - <div className="bg-muted p-4 rounded-md"> - <h4 className="text-sm font-medium mb-2">안내 사항</h4> - <ul className="text-sm space-y-1 text-muted-foreground"> - <li className="flex items-start gap-2"> - <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span>단가와 납품일은 필수로 입력해야 합니다.</span> - </li> - <li className="flex items-start gap-2"> - <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span> - </li> - <li className="flex items-start gap-2"> - <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" /> - <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span> - </li> - </ul> - </div> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx deleted file mode 100644 index 1fb225d8..00000000 --- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx +++ /dev/null @@ -1,333 +0,0 @@ -"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?: { - id?: number; - rfqCode?: string; - status?: string; - dueDate?: Date | string | null; - rfqSendDate?: Date | string | null; - item?: { - id?: number; - itemCode?: string; - itemName?: string; - } | null; - } | null; - vendor?: { - id?: number; - vendorName?: string; - vendorCode?: string; - } | null; -} - -type NextRouter = ReturnType<typeof useRouter>; - -interface GetColumnsProps { - setRowAction: React.Dispatch< - React.SetStateAction<DataTableRowAction<QuotationWithRfqCode> | null> - > - router: NextRouter -} - -/** - * tanstack table 컬럼 정의 (RfqsTable 스타일) - */ -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, - } - - // ---------------------------------------------------------------- - // 2) actions 컬럼 - // ---------------------------------------------------------------- - const actionsColumn: 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-ship/${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, - } - - // ---------------------------------------------------------------- - // 3) 컬럼 정의 배열 - // ---------------------------------------------------------------- - const columnDefinitions = [ - { - id: "quotationCode", - label: "RFQ 번호", - group: null, - size: 150, - minSize: 100, - maxSize: 200, - }, - { - id: "quotationVersion", - label: "RFQ 버전", - group: null, - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "itemCode", - label: "자재 그룹 코드", - group: "RFQ 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "itemName", - label: "자재 이름", - group: "RFQ 정보", - // size를 제거하여 유연한 크기 조정 허용 - minSize: 150, - maxSize: 300, - }, - { - id: "rfqSendDate", - label: "RFQ 송부일", - group: "날짜 정보", - size: 150, - minSize: 120, - maxSize: 180, - }, - { - id: "dueDate", - label: "RFQ 마감일", - group: "날짜 정보", - size: 150, - minSize: 120, - maxSize: 180, - }, - { - id: "status", - label: "상태", - group: null, - size: 100, - minSize: 80, - maxSize: 120, - }, - { - id: "totalPrice", - label: "총액", - group: null, - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "submittedAt", - label: "제출일", - group: "날짜 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "validUntil", - label: "유효기간", - group: "날짜 정보", - size: 120, - minSize: 100, - maxSize: 150, - }, - ]; - - // ---------------------------------------------------------------- - // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성) - // ---------------------------------------------------------------- - const groupMap: Record<string, ColumnDef<QuotationWithRfqCode>[]> = {} - - columnDefinitions.forEach((cfg) => { - const groupName = cfg.group || "_noGroup" - - if (!groupMap[groupName]) { - groupMap[groupName] = [] - } - - // 개별 컬럼 정의 - const columnDef: ColumnDef<QuotationWithRfqCode> = { - accessorKey: cfg.id, - enableResizing: true, - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title={cfg.label} /> - ), - cell: ({ row, cell }) => { - // 각 컬럼별 특별한 렌더링 처리 - switch (cfg.id) { - case "quotationCode": - return row.original.quotationCode || "-" - - case "quotationVersion": - return row.original.quotationVersion || "-" - - case "itemCode": - const itemCode = row.original.rfq?.item?.itemCode; - return itemCode ? itemCode : "-"; - - case "itemName": - const itemName = row.original.rfq?.item?.itemName; - return itemName ? itemName : "-"; - - case "rfqSendDate": - const sendDate = row.original.rfq?.rfqSendDate; - return sendDate ? formatDateTime(new Date(sendDate)) : "-"; - - case "dueDate": - const dueDate = row.original.rfq?.dueDate; - return dueDate ? formatDateTime(new Date(dueDate)) : "-"; - - case "status": - return <StatusBadge status={row.getValue("status") as string} /> - - case "totalPrice": - const price = parseFloat(row.getValue("totalPrice") as string || "0") - const currency = row.original.currency - return formatCurrency(price, currency) - - case "submittedAt": - const submitDate = row.getValue("submittedAt") as string | null - return submitDate ? formatDate(new Date(submitDate)) : "-" - - case "validUntil": - const validDate = row.getValue("validUntil") as string | null - return validDate ? formatDate(new Date(validDate)) : "-" - - default: - return row.getValue(cfg.id) ?? "" - } - }, - size: cfg.size, - minSize: cfg.minSize, - maxSize: cfg.maxSize, - } - - groupMap[groupName].push(columnDef) - }) - - // ---------------------------------------------------------------- - // 5) 그룹별 중첩 컬럼 생성 - // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<QuotationWithRfqCode>[] = [] - Object.entries(groupMap).forEach(([groupName, colDefs]) => { - if (groupName === "_noGroup") { - // 그룹이 없는 컬럼들은 직접 추가 - nestedColumns.push(...colDefs) - } else { - // 그룹이 있는 컬럼들은 중첩 구조로 추가 - nestedColumns.push({ - id: groupName, - header: groupName, - columns: colDefs, - }) - } - }) - - // ---------------------------------------------------------------- - // 6) 최종 컬럼 배열 - // ---------------------------------------------------------------- - return [ - selectColumn, - ...nestedColumns, - actionsColumn, - ] -}
\ 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 deleted file mode 100644 index 7ea0c69e..00000000 --- a/lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx +++ /dev/null @@ -1,152 +0,0 @@ -// 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 훅 사용 (RfqsTable 스타일로 개선) - const { table } = useDataTable({ - data, - columns, - pageCount, - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - enableColumnResizing: true, // 컬럼 크기 조정 허용 - columnResizeMode: 'onChange', // 실시간 크기 조정 - initialState: { - sorting: [{ id: "updatedAt", desc: true }], - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - defaultColumn: { - minSize: 50, - maxSize: 500, - }, - }); - - return ( - <div className="w-full"> - <div className="overflow-x-auto"> - <DataTable - table={table} - className="min-w-full" - > - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - </DataTableAdvancedToolbar> - </DataTable> - </div> - </div> - ); -}
\ No newline at end of file |
