summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs
diff options
context:
space:
mode:
Diffstat (limited to 'lib/procurement-rfqs')
-rw-r--r--lib/procurement-rfqs/repository.ts50
-rw-r--r--lib/procurement-rfqs/services.ts2050
-rw-r--r--lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx512
-rw-r--r--lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx150
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx393
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx521
-rw-r--r--lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx449
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx518
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx665
-rw-r--r--lib/procurement-rfqs/table/pr-item-dialog.tsx258
-rw-r--r--lib/procurement-rfqs/table/rfq-filter-sheet.tsx686
-rw-r--r--lib/procurement-rfqs/table/rfq-table-column.tsx373
-rw-r--r--lib/procurement-rfqs/table/rfq-table-toolbar-actions.tsx279
-rw-r--r--lib/procurement-rfqs/table/rfq-table.tsx412
-rw-r--r--lib/procurement-rfqs/validations.ts61
-rw-r--r--lib/procurement-rfqs/vendor-response/buyer-communication-drawer.tsx522
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-editor.tsx955
-rw-r--r--lib/procurement-rfqs/vendor-response/quotation-item-editor.tsx664
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table-columns.tsx333
-rw-r--r--lib/procurement-rfqs/vendor-response/table/vendor-quotations-table.tsx152
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>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({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>
- 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({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