diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-18 00:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-18 00:23:40 +0000 |
| commit | cf8dac0c6490469dab88a560004b0c07dbd48612 (patch) | |
| tree | b9e76061e80d868331e6b4277deecb9086f845f3 /lib/rfq-last | |
| parent | e5745fc0268bbb5770bc14a55fd58a0ec30b466e (diff) | |
(대표님) rfq, 계약, 서명 등
Diffstat (limited to 'lib/rfq-last')
| -rw-r--r-- | lib/rfq-last/contract-actions.ts | 329 | ||||
| -rw-r--r-- | lib/rfq-last/quotation-compare-view.tsx | 447 | ||||
| -rw-r--r-- | lib/rfq-last/service.ts | 134 | ||||
| -rw-r--r-- | lib/rfq-last/table/create-general-rfq-dialog.tsx | 4 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-assign-pic-dialog.tsx | 311 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 402 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 100 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/send-rfq-dialog.tsx | 378 |
8 files changed, 1489 insertions, 616 deletions
diff --git a/lib/rfq-last/contract-actions.ts b/lib/rfq-last/contract-actions.ts index 1144cf4f..082716a0 100644 --- a/lib/rfq-last/contract-actions.ts +++ b/lib/rfq-last/contract-actions.ts @@ -1,9 +1,15 @@ "use server"; import db from "@/db/db"; -import { rfqsLast, rfqLastDetails } from "@/db/schema"; +import { rfqsLast, rfqLastDetails,rfqPrItems, + prItemsForBidding,biddingConditions,biddingCompanies, projects, + biddings,generalContracts ,generalContractItems} from "@/db/schema"; import { eq, and } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { generateContractNumber } from "../general-contracts/service"; +import { generateBiddingNumber } from "../bidding/service"; // ===== PO (SAP) 생성 ===== interface CreatePOParams { @@ -63,13 +69,13 @@ export async function createPO(params: CreatePOParams) { ); // RFQ 상태 업데이트 - await tx - .update(rfqsLast) - .set({ - status: "PO 생성 완료", - updatedAt: new Date(), - }) - .where(eq(rfqsLast.id, params.rfqId)); + // await tx + // .update(rfqsLast) + // .set({ + // status: "PO 생성 완료", + // updatedAt: new Date(), + // }) + // .where(eq(rfqsLast.id, params.rfqId)); }); revalidatePath(`/rfq/${params.rfqId}`); @@ -89,14 +95,13 @@ export async function createPO(params: CreatePOParams) { } } -// ===== 일반계약 생성 ===== interface CreateGeneralContractParams { rfqId: number; vendorId: number; vendorName: string; totalAmount: number; currency: string; - contractType?: string; + contractType: string; // 계약종류 추가 (UP, LE, IL 등) contractStartDate?: Date; contractEndDate?: Date; contractTerms?: string; @@ -104,40 +109,126 @@ interface CreateGeneralContractParams { export async function createGeneralContract(params: CreateGeneralContractParams) { try { - const userId = 1; // TODO: 실제 사용자 ID 가져오기 + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } - // 1. 선정된 업체 확인 - const [selectedVendor] = await db - .select() - .from(rfqLastDetails) + const userId = session.user.id; + + // 1. RFQ 정보 및 선정된 업체 확인 + const [rfqData] = await db + .select({ + rfq: rfqsLast, + vendor: rfqLastDetails, + project: projects, + }) + .from(rfqsLast) + .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id)) + .leftJoin(projects, eq(projects.id, rfqsLast.projectId)) .where( and( - eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqsLast.id, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true), eq(rfqLastDetails.isLatest, true) ) ); - if (!selectedVendor) { - throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + if (!rfqData || !rfqData.vendor) { + throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다."); } - // 2. 계약 생성 로직 (TODO: 실제 구현 필요) - // - 계약서 템플릿 선택 - // - 계약 조건 설정 - // - 계약서 문서 생성 - // - 전자서명 프로세스 시작 + // 2. PR 아이템 정보 조회 (계약 아이템으로 변환용) + const prItems = await db + .select() + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); - // 3. 계약 상태 업데이트 - await db.transaction(async (tx) => { - // rfqLastDetails에 계약 정보 업데이트 + // 3. 계약번호 생성 - generateContractNumber 함수 사용 + const contractNumber = await generateContractNumber( + params.contractType, // 계약종류 (UP, LE, IL 등) + rfqData.rfq.picCode || undefined // 발주담당자 코드 + ); + + // 4. 트랜잭션으로 계약 생성 + const result = await db.transaction(async (tx) => { + // 4-1. generalContracts 테이블에 계약 생성 + const [newContract] = await tx + .insert(generalContracts) + .values({ + contractNumber, + contractSourceType: 'estimate', // 견적에서 넘어온 계약 + status: 'Draft', // 초안 상태로 시작 + category: '일반계약', + type: params.contractType, // 계약종류 저장 (UP, LE, IL 등) + executionMethod: '일반계약', + name: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 일반계약`, + selectionMethod: '견적', + + // 업체 정보 + vendorId: params.vendorId, + + // 계약 기간 + startDate: params.contractStartDate || new Date(), + endDate: params.contractEndDate || null, + + // 연계 정보 + linkedRfqOrItb: rfqData.rfq.rfqCode || undefined, + + // 금액 정보 + contractAmount: params.totalAmount, + currency: params.currency || 'KRW', + totalAmount: params.totalAmount, + + // 조건 정보 (RFQ에서 가져옴) + contractCurrency: rfqData.vendor.currency || 'KRW', + paymentTerm: rfqData.vendor.paymentTermsCode || undefined, + taxType: rfqData.vendor.taxCode || undefined, + deliveryTerm: rfqData.vendor.incotermsCode || undefined, + shippingLocation: rfqData.vendor.placeOfShipping || undefined, + dischargeLocation: rfqData.vendor.placeOfDestination || undefined, + contractDeliveryDate: rfqData.vendor.deliveryDate || undefined, + + // 연동제 적용 여부 + interlockingSystem: rfqData.vendor.materialPriceRelatedYn ? 'Y' : 'N', + + // 시스템 정보 + registeredById: userId, + registeredAt: new Date(), + lastUpdatedById: userId, + lastUpdatedAt: new Date(), + notes: params.contractTerms || rfqData.vendor.remark || undefined, + }) + .returning(); + + // 4-2. generalContractItems 테이블에 아이템 생성 + if (prItems.length > 0) { + const contractItems = prItems.map(item => ({ + contractId: newContract.id, + project: rfqData.project?.name || undefined, + itemCode: item.materialCode || undefined, + itemInfo: `${item.materialCategory || ''} / ${item.materialCode || ''}`, + specification: item.materialDescription || undefined, + quantity: item.quantity, + quantityUnit: item.uom || undefined, + contractDeliveryDate: item.deliveryDate || undefined, + contractCurrency: params.currency || 'KRW', + // 단가와 금액은 견적 데이터에서 가져와야 함 (현재는 총액을 아이템 수로 나눔) + contractUnitPrice: params.totalAmount / prItems.length, + contractAmount: params.totalAmount / prItems.length, + })); + + await tx.insert(generalContractItems).values(contractItems); + } + + // 4-3. rfqLastDetails 상태 업데이트 await tx .update(rfqLastDetails) .set({ - contractStatus: "진행중", + contractStatus: "일반계약 진행중", contractCreatedAt: new Date(), - contractNo: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호로 변경 + contractNo: contractNumber, updatedAt: new Date(), updatedBy: userId, }) @@ -149,23 +240,18 @@ export async function createGeneralContract(params: CreateGeneralContractParams) ) ); - // RFQ 상태 업데이트 - await tx - .update(rfqsLast) - .set({ - status: "일반계약 진행중", - updatedAt: new Date(), - }) - .where(eq(rfqsLast.id, params.rfqId)); + return newContract; }); revalidatePath(`/rfq/${params.rfqId}`); revalidatePath("/rfq"); + revalidatePath("/contracts"); return { success: true, message: "일반계약이 성공적으로 생성되었습니다.", - contractNumber: `CONTRACT-${Date.now()}`, // TODO: 실제 계약번호 반환 + contractNumber: result.contractNumber, + contractId: result.id, }; } catch (error) { console.error("일반계약 생성 오류:", error); @@ -183,49 +269,162 @@ interface CreateBiddingParams { vendorName: string; totalAmount: number; currency: string; - biddingType?: string; // 공개입찰, 제한입찰 등 - biddingStartDate?: Date; - biddingEndDate?: Date; + contractType: "unit_price" | "general" | "sale"; // 계약구분 + biddingType: string; // 입찰유형 (equipment, construction 등) + awardCount: "single" | "multiple"; // 낙찰수 + biddingStartDate: Date; + biddingEndDate: Date; biddingRequirements?: string; } export async function createBidding(params: CreateBiddingParams) { try { - const userId = 1; // TODO: 실제 사용자 ID 가져오기 + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } - // 1. 선정된 업체 확인 - const [selectedVendor] = await db - .select() - .from(rfqLastDetails) + const userId = session.user.id; + + // 1. RFQ 정보 및 선정된 업체 확인 + const [rfqData] = await db + .select({ + rfq: rfqsLast, + vendor: rfqLastDetails, + project: projects, + }) + .from(rfqsLast) + .leftJoin(rfqLastDetails, eq(rfqLastDetails.rfqsLastId, rfqsLast.id)) + .leftJoin(projects, eq(projects.id, rfqsLast.projectId)) .where( and( - eq(rfqLastDetails.rfqsLastId, params.rfqId), + eq(rfqsLast.id, params.rfqId), eq(rfqLastDetails.vendorsId, params.vendorId), eq(rfqLastDetails.isSelected, true), eq(rfqLastDetails.isLatest, true) ) ); - if (!selectedVendor) { - throw new Error("선정된 업체 정보를 찾을 수 없습니다."); + if (!rfqData || !rfqData.vendor) { + throw new Error("RFQ 정보 또는 선정된 업체 정보를 찾을 수 없습니다."); } - // 2. 입찰 생성 로직 (TODO: 실제 구현 필요) - // - 입찰 공고 생성 - // - 입찰 참가자격 설정 - // - 입찰 일정 등록 - // - 평가 기준 설정 - // - 입찰 시스템 등록 + // 2. PR 아이템 정보 조회 + const prItems = await db + .select() + .from(rfqPrItems) + .where(eq(rfqPrItems.rfqsLastId, params.rfqId)); - // 3. 입찰 상태 업데이트 - await db.transaction(async (tx) => { - // rfqLastDetails에 입찰 정보 업데이트 + // 3. 트랜잭션으로 입찰 생성 + const result = await db.transaction(async (tx) => { + // 3-1. 입찰번호 생성 - generateBiddingNumber 함수 사용 + const biddingNumber = await generateBiddingNumber( + rfqData.rfq.picCode || undefined, // 발주담당자 코드 + tx // 트랜잭션 컨텍스트 전달 + ); + + // 3-2. biddings 테이블에 입찰 생성 + const [newBidding] = await tx + .insert(biddings) + .values({ + biddingNumber, + revision: 0, + biddingSourceType: 'estimate', + projectId: rfqData.rfq.projectId || undefined, + + // 기본 정보 + projectName: rfqData.project?.name || undefined, + itemName: rfqData.rfq.itemName || undefined, + title: `${rfqData.project?.name || rfqData.rfq.itemName || ''} 입찰`, + description: params.biddingRequirements || rfqData.rfq.remark || undefined, + + // 계약 정보 - 파라미터에서 받은 값 사용 + contractType: params.contractType, + biddingType: params.biddingType, + awardCount: params.awardCount, + + // 일정 관리 - 파라미터에서 받은 날짜 사용 + biddingRegistrationDate: new Date(), + submissionStartDate: params.biddingStartDate, + submissionEndDate: params.biddingEndDate, + + // 예산 및 가격 정보 + currency: params.currency || 'KRW', + budget: params.totalAmount, + targetPrice: params.totalAmount, + + // PR 정보 + prNumber: rfqData.rfq.prNumber || undefined, + hasPrDocument: false, + + // 상태 + status: 'bidding_generated', + isPublic: false, // 다이얼로그에서 체크박스로 받을 수 있음 + isUrgent: false, // 다이얼로그에서 체크박스로 받을 수 있음 + + // 담당자 정보 + managerName: rfqData.rfq.picName || undefined, + + // 메타 정보 + createdBy: String(userId), + createdAt: new Date(), + updatedAt: new Date(), + updatedBy: String(userId), + ANFNR: rfqData.rfq.ANFNR || undefined, + }) + .returning(); + + // 3-3. PR 아이템을 입찰 아이템으로 변환 + if (prItems.length > 0) { + const biddingPrItems = prItems.map(item => ({ + biddingId: newBidding.id, + itemNumber: item.rfqItem || undefined, + projectInfo: rfqData.project?.name || undefined, // 프로젝트 이름 사용 + itemInfo: item.materialDescription || undefined, + requestedDeliveryDate: item.deliveryDate || undefined, + currency: params.currency || 'KRW', + quantity: item.quantity, + quantityUnit: item.uom || undefined, + totalWeight: item.grossWeight || undefined, + weightUnit: item.gwUom || undefined, + materialDescription: item.materialDescription || undefined, + prNumber: item.prNo || undefined, + hasSpecDocument: !!item.specUrl, + })); + + await tx.insert(prItemsForBidding).values(biddingPrItems); + } + + // 3-4. 선정된 업체를 입찰 참여 업체로 등록 + await tx.insert(biddingCompanies).values({ + biddingId: newBidding.id, + companyId: params.vendorId, + invitationStatus: 'pending', + preQuoteAmount: params.totalAmount, // 견적 금액을 사전견적으로 사용 + isPreQuoteSelected: true, // 본입찰 대상으로 자동 선정 + isBiddingInvited: true, + notes: '견적에서 선정된 업체', + }); + + // 3-5. 입찰 조건 생성 (RFQ 조건 활용) + await tx.insert(biddingConditions).values({ + biddingId: newBidding.id, + paymentTerms: JSON.stringify([rfqData.vendor.paymentTermsCode]), + taxConditions: JSON.stringify([rfqData.vendor.taxCode]), + contractDeliveryDate: rfqData.vendor.deliveryDate || undefined, + isPriceAdjustmentApplicable: rfqData.vendor.materialPriceRelatedYn || false, + incoterms: JSON.stringify([rfqData.vendor.incotermsCode]), + shippingPort: rfqData.vendor.placeOfShipping || undefined, + destinationPort: rfqData.vendor.placeOfDestination || undefined, + }); + + // 3-6. rfqLastDetails 상태 업데이트 await tx .update(rfqLastDetails) .set({ contractStatus: "입찰진행중", contractCreatedAt: new Date(), - contractNo: `BID-${Date.now()}`, // TODO: 실제 입찰번호로 변경 + contractNo: biddingNumber, updatedAt: new Date(), updatedBy: userId, }) @@ -237,23 +436,18 @@ export async function createBidding(params: CreateBiddingParams) { ) ); - // RFQ 상태 업데이트 - await tx - .update(rfqsLast) - .set({ - status: "입찰 진행중", - updatedAt: new Date(), - }) - .where(eq(rfqsLast.id, params.rfqId)); + return newBidding; }); revalidatePath(`/rfq/${params.rfqId}`); revalidatePath("/rfq"); + revalidatePath("/biddings"); return { success: true, message: "입찰이 성공적으로 생성되었습니다.", - biddingNumber: `BID-${Date.now()}`, // TODO: 실제 입찰번호 반환 + biddingNumber: result.biddingNumber, + biddingId: result.id, }; } catch (error) { console.error("입찰 생성 오류:", error); @@ -263,7 +457,6 @@ export async function createBidding(params: CreateBiddingParams) { }; } } - // ===== 계약 타입 확인 ===== export async function checkContractStatus(rfqId: number) { try { diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 491a1962..28c8b3b1 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -46,6 +46,7 @@ import { import { ComparisonData, selectVendor, cancelVendorSelection } from "./compare-action"; import { createPO, createGeneralContract, createBidding } from "./contract-actions"; import { toast } from "sonner"; +import { useRouter } from "next/navigation" interface QuotationCompareViewProps { data: ComparisonData; @@ -61,6 +62,61 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { const [selectionReason, setSelectionReason] = React.useState(""); const [cancelReason, setCancelReason] = React.useState(""); const [isSubmitting, setIsSubmitting] = React.useState(false); + const router = useRouter() + + const [selectedGeneralContractType, setSelectedGeneralContractType] = React.useState(""); + const [contractStartDate, setContractStartDate] = React.useState(""); + const [contractEndDate, setContractEndDate] = React.useState(""); + + // 계약종류 옵션 + const contractTypes = [ + { value: 'UP', label: 'UP - 자재단가계약' }, + { value: 'LE', label: 'LE - 임대차계약' }, + { value: 'IL', label: 'IL - 개별운송계약' }, + { value: 'AL', label: 'AL - 연간운송계약' }, + { value: 'OS', label: 'OS - 외주용역계약' }, + { value: 'OW', label: 'OW - 도급계약' }, + { value: 'IS', label: 'IS - 검사계약' }, + { value: 'LO', label: 'LO - LOI (의향서)' }, + { value: 'FA', label: 'FA - Frame Agreement' }, + { value: 'SC', label: 'SC - 납품합의계약' }, + { value: 'OF', label: 'OF - 클레임상계계약' }, + { value: 'AW', label: 'AW - 사전작업합의' }, + { value: 'AD', label: 'AD - 사전납품합의' }, + { value: 'AM', label: 'AM - 설계계약' }, + { value: 'SC_SELL', label: 'SC - 폐기물매각계약' }, + ]; + + // 입찰 관련 state 추가 + const [biddingContractType, setBiddingContractType] = React.useState<"unit_price" | "general" | "sale" | "">(""); + const [biddingType, setBiddingType] = React.useState<string>(""); + const [awardCount, setAwardCount] = React.useState<"single" | "multiple">("single"); + const [biddingStartDate, setBiddingStartDate] = React.useState(""); + const [biddingEndDate, setBiddingEndDate] = React.useState(""); + + // 입찰 옵션들 + const biddingContractTypes = [ + { value: 'unit_price', label: '단가계약' }, + { value: 'general', label: '일반계약' }, + { value: 'sale', label: '매각계약' } + ]; + + const biddingTypes = [ + { value: 'equipment', label: '기자재' }, + { value: 'construction', label: '공사' }, + { value: 'service', label: '용역' }, + { value: 'lease', label: '임차' }, + { value: 'steel_stock', label: '형강스톡' }, + { value: 'piping', label: '배관' }, + { value: 'transport', label: '운송' }, + { value: 'waste', label: '폐기물' }, + { value: 'sale', label: '매각' } + ]; + + const awardCounts = [ + { value: 'single', label: '단수' }, + { value: 'multiple', label: '복수' } + ]; // 선정된 업체 정보 확인 const selectedVendor = data.vendors.find(v => v.isSelected); @@ -76,15 +132,56 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { return; } + // 일반계약인 경우 계약종류와 날짜 확인 + if (selectedContractType === "CONTRACT") { + if (!selectedGeneralContractType) { + toast.error("계약종류를 선택해주세요."); + return; + } + if (!contractStartDate) { + toast.error("계약 시작일을 선택해주세요."); + return; + } + if (!contractEndDate) { + toast.error("계약 종료일을 선택해주세요."); + return; + } + if (new Date(contractStartDate) >= new Date(contractEndDate)) { + toast.error("계약 종료일은 시작일보다 이후여야 합니다."); + return; + } + } + + // 입찰 검증 + if (selectedContractType === "BIDDING") { + if (!biddingContractType) { + toast.error("계약구분을 선택해주세요."); + return; + } + if (!biddingType) { + toast.error("입찰유형을 선택해주세요."); + return; + } + if (!biddingStartDate || !biddingEndDate) { + toast.error("입찰 기간을 입력해주세요."); + return; + } + if (new Date(biddingStartDate) >= new Date(biddingEndDate)) { + toast.error("입찰 마감일은 시작일보다 이후여야 합니다."); + return; + } + } + if (!selectedVendor) { toast.error("선정된 업체가 없습니다."); return; } setIsSubmitting(true); + try { let result; - + switch (selectedContractType) { case "PO": result = await createPO({ @@ -94,9 +191,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { totalAmount: selectedVendor.totalAmount, currency: selectedVendor.currency, selectionReason: selectedVendor.selectionReason, + }); break; - + case "CONTRACT": result = await createGeneralContract({ rfqId: data.rfqInfo.id, @@ -104,9 +202,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { vendorName: selectedVendor.vendorName, totalAmount: selectedVendor.totalAmount, currency: selectedVendor.currency, + contractStartDate: new Date(contractStartDate), + contractEndDate: new Date(contractEndDate), + contractType: selectedGeneralContractType, // 계약종류 추가 + }); break; - + case "BIDDING": result = await createBidding({ rfqId: data.rfqInfo.id, @@ -114,9 +216,14 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { vendorName: selectedVendor.vendorName, totalAmount: selectedVendor.totalAmount, currency: selectedVendor.currency, + contractType: biddingContractType, + biddingType: biddingType, + awardCount: awardCount, + biddingStartDate: new Date(biddingStartDate), + biddingEndDate: new Date(biddingEndDate), }); break; - + default: throw new Error("올바른 계약 유형이 아닙니다."); } @@ -124,8 +231,17 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { if (result.success) { toast.success(result.message || "계약 프로세스가 시작되었습니다."); setShowContractDialog(false); + // 모든 state 초기화 setSelectedContractType(""); - window.location.reload(); + setSelectedGeneralContractType(""); + setContractStartDate(""); + setContractEndDate(""); + setBiddingContractType(""); + setBiddingType(""); + setAwardCount("single"); + setBiddingStartDate(""); + setBiddingEndDate(""); + router.refresh(); } else { throw new Error(result.error || "계약 진행 중 오류가 발생했습니다."); } @@ -224,7 +340,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { toast.success("업체가 성공적으로 선정되었습니다."); setShowSelectionDialog(false); setSelectionReason(""); - window.location.reload(); // 페이지 새로고침으로 선정 상태 반영 + router.refresh() } else { throw new Error(result.error || "업체 선정 중 오류가 발생했습니다."); } @@ -246,13 +362,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { setIsSubmitting(true); try { // 파라미터를 올바르게 전달 - const result = await cancelVendorSelection(Number(data.rfqInfo.id),cancelReason); + const result = await cancelVendorSelection(Number(data.rfqInfo.id), cancelReason); if (result.success) { toast.success("업체 선정이 취소되었습니다."); setShowCancelDialog(false); setCancelReason(""); - window.location.reload(); + router.refresh() } else { throw new Error(result.error || "선정 취소 중 오류가 발생했습니다."); } @@ -312,13 +428,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {hasSelection && ( <Alert className={cn( "border-2", - hasContract + hasContract ? "border-purple-500 bg-purple-50" - : isSelectionApproved - ? "border-green-500 bg-green-50" - : isPendingApproval - ? "border-yellow-500 bg-yellow-50" - : "border-blue-500 bg-blue-50" + : isSelectionApproved + ? "border-green-500 bg-green-50" + : isPendingApproval + ? "border-yellow-500 bg-yellow-50" + : "border-blue-500 bg-blue-50" )}> <div className="flex items-start justify-between"> <div className="flex gap-3"> @@ -335,11 +451,11 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <AlertTitle className="text-lg"> {hasContract ? "계약 진행중" - : isSelectionApproved - ? "업체 선정 승인 완료" - : isPendingApproval - ? "업체 선정 승인 대기중" - : "업체 선정 완료"} + : isSelectionApproved + ? "업체 선정 승인 완료" + : isPendingApproval + ? "업체 선정 승인 대기중" + : "업체 선정 완료"} </AlertTitle> <AlertDescription className="space-y-1"> <p className="font-semibold">선정 업체: {selectedVendor.vendorName} ({selectedVendor.vendorCode})</p> @@ -521,13 +637,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { key={vendor.vendorId} className={cn( "flex items-center justify-between p-4 border rounded-lg transition-colors", - vendor.isSelected - ? "bg-blue-100 border-blue-400 border-2" + vendor.isSelected + ? "bg-blue-100 border-blue-400 border-2" : hasSelection - ? "opacity-60" - : selectedVendorId === vendor.vendorId.toString() - ? "bg-blue-50 border-blue-300 cursor-pointer" - : "hover:bg-gray-50 cursor-pointer" + ? "opacity-60" + : selectedVendorId === vendor.vendorId.toString() + ? "bg-blue-50 border-blue-300 cursor-pointer" + : "hover:bg-gray-50 cursor-pointer" )} onClick={() => { if (!hasSelection) { @@ -683,10 +799,10 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </TooltipContent> </Tooltip> </TooltipProvider> - {vendor.vendorConditions.paymentTermsCode && - vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( - <Badge variant="outline" className="text-xs">변경</Badge> - )} + {vendor.vendorConditions.paymentTermsCode && + vendor.vendorConditions.paymentTermsCode !== vendor.buyerConditions.paymentTermsCode && ( + <Badge variant="outline" className="text-xs">변경</Badge> + )} </div> </td> ))} @@ -770,8 +886,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {quote.deliveryDate ? format(new Date(quote.deliveryDate), "yyyy-MM-dd") : quote.leadTime - ? `${quote.leadTime}일` - : "-"} + ? `${quote.leadTime}일` + : "-"} </td> <td className="p-2"> {quote.manufacturer && ( @@ -817,7 +933,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <div> <p className="text-muted-foreground">가격 차이</p> <p className="font-semibold"> - {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) / + {((item.priceAnalysis.highestPrice - item.priceAnalysis.lowestPrice) / item.priceAnalysis.lowestPrice * 100).toFixed(1)}% </p> </div> @@ -848,7 +964,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <div className="space-y-3"> {data.vendors.map((vendor) => { if (!vendor.conditionDifferences.hasDifferences) return null; - + return ( <div key={vendor.vendorId} className="p-3 border rounded-lg"> <p className="font-medium mb-2">{vendor.vendorName}</p> @@ -930,12 +1046,12 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { // 가격 순위와 조건 차이를 고려한 점수 계산 const scoredVendors = data.vendors.map(v => ({ ...v, - score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 + - v.conditionDifferences.differences.length + score: (v.rank || 10) + v.conditionDifferences.criticalDifferences.length * 3 + + v.conditionDifferences.differences.length })); scoredVendors.sort((a, b) => a.score - b.score); const recommended = scoredVendors[0]; - + return ( <div> <p className="text-sm text-purple-700"> @@ -963,7 +1079,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <div className="space-y-4"> {data.vendors.map((vendor) => { if (!vendor.generalRemark && !vendor.technicalProposal) return null; - + return ( <div key={vendor.vendorId} className="border rounded-lg p-4"> <p className="font-medium mb-2">{vendor.vendorName}</p> @@ -996,7 +1112,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <h3 className="text-lg font-semibold mb-4"> {hasSelection ? "업체 재선정 확인" : "업체 선정 확인"} </h3> - + {selectedVendorId && ( <div className="space-y-4"> <div className="rounded-lg border p-4"> @@ -1076,7 +1192,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> <h3 className="text-lg font-semibold mb-4">업체 선정 취소</h3> - + <Alert className="mb-4"> <AlertTriangle className="h-4 w-4" /> <AlertDescription> @@ -1141,13 +1257,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { {/* 계약 진행 모달 */} {showContractDialog && ( <div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center"> - <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4"> + <div className="bg-white rounded-lg p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto"> <h3 className="text-lg font-semibold mb-4"> {selectedContractType === "PO" && "PO (SAP) 생성"} {selectedContractType === "CONTRACT" && "일반계약 생성"} {selectedContractType === "BIDDING" && "입찰 생성"} </h3> - + {selectedVendor && ( <div className="space-y-4"> <div className="rounded-lg border p-4 bg-gray-50"> @@ -1180,32 +1296,222 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </AlertDescription> </Alert> - {/* 추가 옵션이 필요한 경우 여기에 추가 */} + {/* 일반계약 선택 시 계약종류 및 기간 선택 */} {selectedContractType === "CONTRACT" && ( - <div className="space-y-2"> - <p className="text-sm font-medium">계약 옵션</p> - <div className="space-y-2 text-sm"> - <label className="flex items-center gap-2"> - <input type="checkbox" className="rounded" /> - <span>표준계약서 사용</span> - </label> - <label className="flex items-center gap-2"> - <input type="checkbox" className="rounded" /> - <span>전자서명 요청</span> + <div className="space-y-4"> + {/* 계약종류 선택 */} + <div className="space-y-2"> + <label htmlFor="contract-type" className="text-sm font-medium"> + 계약종류 선택 * </label> + <select + id="contract-type" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + value={selectedGeneralContractType} + onChange={(e) => setSelectedGeneralContractType(e.target.value)} + required + > + <option value="">계약종류를 선택하세요</option> + {contractTypes.map(type => ( + <option key={type.value} value={type.value}> + {type.label} + </option> + ))} + </select> + </div> + + {/* 계약 기간 */} + <div className="grid grid-cols-2 gap-3"> + <div className="space-y-2"> + <label htmlFor="start-date" className="text-sm font-medium"> + 계약 시작일 * + </label> + <input + id="start-date" + type="date" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + value={contractStartDate} + onChange={(e) => setContractStartDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + required + /> + </div> + + <div className="space-y-2"> + <label htmlFor="end-date" className="text-sm font-medium"> + 계약 종료일 * + </label> + <input + id="end-date" + type="date" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + value={contractEndDate} + onChange={(e) => setContractEndDate(e.target.value)} + min={contractStartDate || new Date().toISOString().split('T')[0]} + required + /> + </div> + </div> + + {/* 계약 기간 표시 */} + {contractStartDate && contractEndDate && ( + <div className="p-3 bg-blue-50 rounded-md"> + <p className="text-sm text-blue-700"> + 계약 기간: {format(new Date(contractStartDate), "yyyy년 MM월 dd일", { locale: ko })} ~ {format(new Date(contractEndDate), "yyyy년 MM월 dd일", { locale: ko })} + <span className="ml-2 font-medium"> + ({Math.ceil((new Date(contractEndDate).getTime() - new Date(contractStartDate).getTime()) / (1000 * 60 * 60 * 24))}일간) + </span> + </p> + </div> + )} + + {/* 계약 옵션 */} + <div className="space-y-2"> + <p className="text-sm font-medium">추가 옵션</p> + <div className="space-y-2 text-sm"> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>표준계약서 사용</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>전자서명 요청</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>자동 연장 조항 포함</span> + </label> + </div> </div> </div> )} - + {/* 입찰 옵션 */} {selectedContractType === "BIDDING" && ( - <div className="space-y-2"> - <p className="text-sm font-medium">입찰 유형</p> - <select className="w-full px-3 py-2 border rounded-md"> - <option value="">선택하세요</option> - <option value="open">공개입찰</option> - <option value="limited">제한입찰</option> - <option value="private">지명입찰</option> - </select> + <div className="space-y-4"> + {/* 계약구분 선택 */} + <div className="space-y-2"> + <label htmlFor="bidding-contract-type" className="text-sm font-medium"> + 계약구분 * + </label> + <select + id="bidding-contract-type" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={biddingContractType} + onChange={(e) => setBiddingContractType(e.target.value as any)} + required + > + <option value="">계약구분을 선택하세요</option> + {biddingContractTypes.map(type => ( + <option key={type.value} value={type.value}> + {type.label} + </option> + ))} + </select> + </div> + + {/* 입찰유형 선택 */} + <div className="space-y-2"> + <label htmlFor="bidding-type" className="text-sm font-medium"> + 입찰유형 * + </label> + <select + id="bidding-type" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={biddingType} + onChange={(e) => setBiddingType(e.target.value)} + required + > + <option value="">입찰유형을 선택하세요</option> + {biddingTypes.map(type => ( + <option key={type.value} value={type.value}> + {type.label} + </option> + ))} + </select> + </div> + + {/* 낙찰수 선택 */} + <div className="space-y-2"> + <label htmlFor="award-count" className="text-sm font-medium"> + 낙찰수 + </label> + <select + id="award-count" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={awardCount} + onChange={(e) => setAwardCount(e.target.value as any)} + > + {awardCounts.map(type => ( + <option key={type.value} value={type.value}> + {type.label} + </option> + ))} + </select> + </div> + + {/* 입찰 기간 */} + <div className="grid grid-cols-2 gap-3"> + <div className="space-y-2"> + <label htmlFor="bidding-start-date" className="text-sm font-medium"> + 입찰 시작일 * + </label> + <input + id="bidding-start-date" + type="date" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={biddingStartDate} + onChange={(e) => setBiddingStartDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + required + /> + </div> + + <div className="space-y-2"> + <label htmlFor="bidding-end-date" className="text-sm font-medium"> + 입찰 마감일 * + </label> + <input + id="bidding-end-date" + type="date" + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500" + value={biddingEndDate} + onChange={(e) => setBiddingEndDate(e.target.value)} + min={biddingStartDate || new Date().toISOString().split('T')[0]} + required + /> + </div> + </div> + + {/* 입찰 기간 표시 */} + {biddingStartDate && biddingEndDate && ( + <div className="p-3 bg-purple-50 rounded-md"> + <p className="text-sm text-purple-700"> + 입찰 기간: {format(new Date(biddingStartDate), "yyyy년 MM월 dd일", { locale: ko })} ~ {format(new Date(biddingEndDate), "yyyy년 MM월 dd일", { locale: ko })} + <span className="ml-2 font-medium"> + ({Math.ceil((new Date(biddingEndDate).getTime() - new Date(biddingStartDate).getTime()) / (1000 * 60 * 60 * 24))}일간) + </span> + </p> + </div> + )} + + {/* 추가 옵션 */} + <div className="space-y-2"> + <p className="text-sm font-medium">추가 옵션</p> + <div className="space-y-2 text-sm"> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>긴급입찰</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>사양설명회 개최</span> + </label> + <label className="flex items-center gap-2"> + <input type="checkbox" className="rounded" /> + <span>공개입찰</span> + </label> + </div> + </div> </div> )} </div> @@ -1216,7 +1522,16 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { variant="outline" onClick={() => { setShowContractDialog(false); + // 모든 state 초기화 setSelectedContractType(""); + setSelectedGeneralContractType(""); + setContractStartDate(""); + setContractEndDate(""); + setBiddingContractType(""); + setBiddingType(""); + setAwardCount("single"); + setBiddingStartDate(""); + setBiddingEndDate(""); }} disabled={isSubmitting} > @@ -1224,7 +1539,13 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </Button> <Button onClick={handleContractCreation} - disabled={isSubmitting} + disabled={ + isSubmitting || + (selectedContractType === "CONTRACT" && + (!selectedGeneralContractType || !contractStartDate || !contractEndDate)) || + (selectedContractType === "BIDDING" && + (!biddingContractType || !biddingType || !biddingStartDate || !biddingEndDate)) + } > {isSubmitting ? "처리 중..." : "진행"} </Button> @@ -1232,6 +1553,8 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { </div> </div> )} + + </div> ); }
\ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 723a69fe..85db1ea7 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -16,6 +16,7 @@ import { addDays, format } from "date-fns" import { ko, enUS } from "date-fns/locale" import { generateBasicContractsForVendor } from "../basic-contract/gen-service"; import { writeFile, mkdir } from "fs/promises"; +import { generateItbRfqCode } from "../itb/service"; export async function getRfqs(input: GetRfqsSchema) { @@ -37,17 +38,14 @@ export async function getRfqs(input: GetRfqsSchema) { break; case "itb": // ITB: projectCompany가 있는 경우 - typeFilter = and( - isNotNull(rfqsLastView.projectCompany), - ne(rfqsLastView.projectCompany, '') - ); + typeFilter = + like(rfqsLastView.rfqCode,'I%') + + ; break; case "rfq": // RFQ: prNumber가 있는 경우 - typeFilter = and( - isNotNull(rfqsLastView.prNumber), - ne(rfqsLastView.prNumber, '') - ); + typeFilter = like(rfqsLastView.rfqCode,'R%'); break; } } @@ -1854,6 +1852,26 @@ export async function getRfqWithDetails(rfqId: number) { ) .orderBy(desc(rfqLastDetailsView.detailId)); + const tbeSessionsData = await db + .select({ + vendorId: rfqLastTbeSessions.vendorId, + sessionCode: rfqLastTbeSessions.sessionCode, + status: rfqLastTbeSessions.status, + evaluationResult: rfqLastTbeSessions.evaluationResult, + conditionalRequirements: rfqLastTbeSessions.conditionalRequirements, + conditionsFulfilled: rfqLastTbeSessions.conditionsFulfilled, + plannedStartDate: rfqLastTbeSessions.plannedStartDate, + actualStartDate: rfqLastTbeSessions.actualStartDate, + actualEndDate: rfqLastTbeSessions.actualEndDate, + }) + .from(rfqLastTbeSessions) + .where(eq(rfqLastTbeSessions.rfqsLastId, rfqId)); + + const tbeByVendor = tbeSessionsData.reduce((acc, tbe) => { + acc[tbe.vendorId] = tbe; + return acc; + }, {} as Record<number, typeof tbeSessionsData[0]>); + return { success: true, data: { @@ -1990,6 +2008,12 @@ export async function getRfqWithDetails(rfqId: number) { emailResentCount: d.emailResentCount, lastEmailSentAt: d.lastEmailSentAt, emailStatus: d.emailStatus, + + // TBE 정보 추가 + tbeSession: d.vendorId ? tbeByVendor[d.vendorId] : null, + tbeStatus: d.vendorId ? tbeByVendor[d.vendorId]?.status : null, + tbeEvaluationResult: d.vendorId ? tbeByVendor[d.vendorId]?.evaluationResult : null, + tbeSessionCode: d.vendorId ? tbeByVendor[d.vendorId]?.sessionCode : null, })), } }; @@ -2987,19 +3011,25 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) { for (const { attachment, revision } of attachments) { if (revision?.filePath) { + + const cleanPath = revision.filePath.startsWith('/api/files') + ? revision.filePath.slice('/api/files'.length) + : revision.filePath; + try { const isProduction = process.env.NODE_ENV === "production"; - const fullPath = isProduction + + const fullPath = !isProduction ? path.join( process.cwd(), `public`, - revision.filePath + cleanPath ) : path.join( `${process.env.NAS_PATH}`, - revision.filePath + cleanPath ); const fileBuffer = await fs.readFile(fullPath); @@ -3009,7 +3039,8 @@ async function prepareEmailAttachments(rfqId: number, attachmentIds: number[]) { contentType: revision.fileType || 'application/octet-stream' }); } catch (error) { - console.error(`첨부파일 읽기 실패: ${revision.filePath}`, error); + + console.error(`첨부파일 읽기 실패: ${cleanPath}`, error); } } } @@ -4320,4 +4351,83 @@ export async function updateShortList( console.error("Short List 업데이트 실패:", error); throw new Error("Short List 업데이트에 실패했습니다."); } +} + +interface AssignPicParams { + rfqIds: number[]; + picUserId: number; +} + +export async function assignPicToRfqs({ rfqIds, picUserId }: AssignPicParams) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + throw new Error("인증이 필요합니다."); + } + + // 선택된 담당자 정보 조회 + const picUser = await db.query.users.findFirst({ + where: eq(users.id, picUserId), + }); + + if (!picUser) { + throw new Error("선택한 담당자를 찾을 수 없습니다."); + } + + // RFQ 코드가 "I"로 시작하는 것들만 필터링 (추가 검증) + const targetRfqs = await db.query.rfqsLast.findMany({ + where: inArray(rfqsLast.id, rfqIds), + }); + + // "I"로 시작하는 RFQ만 필터링 + const validRfqs = targetRfqs.filter(rfq => rfq.rfqCode?.startsWith("I")); + + if (validRfqs.length === 0) { + throw new Error("담당자를 지정할 수 있는 ITB가 없습니다."); + } + + // 트랜잭션으로 처리하여 동시성 문제 방지 + const updatedCount = await db.transaction(async (tx) => { + let successCount = 0; + + for (const rfq of validRfqs) { + // 각 RFQ에 대해 새로운 코드 생성 + const newRfqCode = await generateItbRfqCode(picUser.id); + + // RFQ 업데이트 + const result = await tx.update(rfqsLast) + .set({ + rfqCode: newRfqCode, // 새로운 RFQ 코드로 업데이트 + pic: picUser.id, + picCode: picUser.userCode || undefined, + picName: picUser.name, + status: "구매담당지정", // 상태도 업데이트 + updatedBy: parseInt(session.user.id), + updatedAt: new Date(), + }) + .where(eq(rfqsLast.id, rfq.id)); + + if (result) { + successCount++; + console.log(`RFQ ${rfq.rfqCode} -> ${newRfqCode} 업데이트 완료`); + } + } + + return successCount; + }); + + revalidatePath("/evcp/rfq-last"); + + return { + success: true, + message: `${updatedCount}건의 ITB에 담당자가 지정되고 코드가 재발급되었습니다.`, + updatedCount + }; + } catch (error) { + console.error("담당자 지정 오류:", error); + return { + success: false, + message: error instanceof Error ? error.message : "담당자 지정 중 오류가 발생했습니다." + }; + } }
\ No newline at end of file diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index 14564686..7abf06a3 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -60,7 +60,7 @@ import { createGeneralRfqAction, getPUsersForFilter, previewGeneralRfqCode } fro // 아이템 스키마 const itemSchema = z.object({ - itemCode: z.string().min(1, "자재코드를 입력해주세요"), + itemCode: z.string().optional(), itemName: z.string().min(1, "자재명을 입력해주세요"), quantity: z.number().min(1, "수량은 1 이상이어야 합니다"), uom: z.string().min(1, "단위를 입력해주세요"), @@ -645,7 +645,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp render={({ field }) => ( <FormItem> <FormLabel className="text-xs"> - 자재코드 <span className="text-red-500">*</span> + 자재코드 </FormLabel> <FormControl> <Input diff --git a/lib/rfq-last/table/rfq-assign-pic-dialog.tsx b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx new file mode 100644 index 00000000..89dda979 --- /dev/null +++ b/lib/rfq-last/table/rfq-assign-pic-dialog.tsx @@ -0,0 +1,311 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown, Loader2, User, Users } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { getPUsersForFilter } from "@/lib/rfq-last/service"; +import { assignPicToRfqs } from "../service"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface User { + id: number; + name: string; + userCode?: string; + email?: string; +} + +interface RfqAssignPicDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedRfqIds: number[]; + selectedRfqCodes: string[]; + onSuccess?: () => void; +} + +export function RfqAssignPicDialog({ + open, + onOpenChange, + selectedRfqIds, + selectedRfqCodes, + onSuccess, +}: RfqAssignPicDialogProps) { + const [users, setUsers] = React.useState<User[]>([]); + const [isLoadingUsers, setIsLoadingUsers] = React.useState(false); + const [isAssigning, setIsAssigning] = React.useState(false); + const [selectedUser, setSelectedUser] = React.useState<User | null>(null); + const [userPopoverOpen, setUserPopoverOpen] = React.useState(false); + const [userSearchTerm, setUserSearchTerm] = React.useState(""); + + // ITB만 필터링 (rfqCode가 "I"로 시작하는 것) + const itbCodes = React.useMemo(() => { + return selectedRfqCodes.filter(code => code.startsWith("I")); + }, [selectedRfqCodes]); + + const itbIds = React.useMemo(() => { + return selectedRfqIds.filter((id, index) => selectedRfqCodes[index]?.startsWith("I")); + }, [selectedRfqIds, selectedRfqCodes]); + + // 유저 목록 로드 + React.useEffect(() => { + const loadUsers = async () => { + setIsLoadingUsers(true); + try { + const userList = await getPUsersForFilter(); + setUsers(userList); + } catch (error) { + console.log("사용자 목록 로드 오류:", error); + toast.error("사용자 목록을 불러오는데 실패했습니다"); + } finally { + setIsLoadingUsers(false); + } + }; + + if (open) { + loadUsers(); + // 다이얼로그 열릴 때 초기화 + setSelectedUser(null); + setUserSearchTerm(""); + } + }, [open]); + + // 유저 검색 + const filteredUsers = React.useMemo(() => { + if (!userSearchTerm) return users; + + const lowerSearchTerm = userSearchTerm.toLowerCase(); + return users.filter( + (user) => + user.name.toLowerCase().includes(lowerSearchTerm) || + user.userCode?.toLowerCase().includes(lowerSearchTerm) + ); + }, [users, userSearchTerm]); + + const handleSelectUser = (user: User) => { + setSelectedUser(user); + setUserPopoverOpen(false); + }; + + const handleAssign = async () => { + if (!selectedUser) { + toast.error("담당자를 선택해주세요"); + return; + } + + if (itbIds.length === 0) { + toast.error("선택한 항목 중 ITB가 없습니다"); + return; + } + + setIsAssigning(true); + try { + const result = await assignPicToRfqs({ + rfqIds: itbIds, + picUserId: selectedUser.id, + }); + + if (result.success) { + toast.success(result.message); + onSuccess?.(); + onOpenChange(false); + } else { + toast.error(result.message); + } + } catch (error) { + console.error("담당자 지정 오류:", error); + toast.error("담당자 지정 중 오류가 발생했습니다"); + } finally { + setIsAssigning(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Users className="h-5 w-5" /> + 담당자 지정 + </DialogTitle> + <DialogDescription> + 선택한 ITB에 구매 담당자를 지정합니다 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 선택된 ITB 정보 */} + <div className="space-y-2"> + <label className="text-sm font-medium">선택된 ITB</label> + <div className="p-3 bg-muted rounded-md"> + <div className="flex items-center gap-2 mb-2"> + <Badge variant="secondary">{itbCodes.length}건</Badge> + {itbCodes.length !== selectedRfqCodes.length && ( + <span className="text-xs text-muted-foreground"> + (전체 {selectedRfqCodes.length}건 중) + </span> + )} + </div> + <div className="max-h-[100px] overflow-y-auto"> + <div className="flex flex-wrap gap-1"> + {itbCodes.slice(0, 10).map((code, index) => ( + <Badge key={index} variant="outline" className="text-xs"> + {code} + </Badge> + ))} + {itbCodes.length > 10 && ( + <Badge variant="outline" className="text-xs"> + +{itbCodes.length - 10}개 + </Badge> + )} + </div> + </div> + </div> + {itbCodes.length === 0 && ( + <Alert className="border-orange-200 bg-orange-50"> + <AlertDescription className="text-orange-800"> + 선택한 항목 중 ITB (I로 시작하는 코드)가 없습니다. + </AlertDescription> + </Alert> + )} + </div> + + {/* 담당자 선택 */} + <div className="space-y-2"> + <label className="text-sm font-medium">구매 담당자</label> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className="w-full justify-between h-10" + disabled={isLoadingUsers || itbCodes.length === 0} + > + {isLoadingUsers ? ( + <> + <span>담당자 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {selectedUser ? ( + <> + {selectedUser.name} + {selectedUser.userCode && ( + <span className="text-muted-foreground"> + ({selectedUser.userCode}) + </span> + )} + </> + ) : ( + <span className="text-muted-foreground"> + 구매 담당자를 선택하세요 + </span> + )} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent className="w-[460px] p-0"> + <Command> + <CommandInput + placeholder="이름 또는 코드로 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <CommandGroup> + {filteredUsers.map((user) => ( + <CommandItem + key={user.id} + onSelect={() => handleSelectUser(user)} + className="flex items-center justify-between" + > + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {user.name} + {user.userCode && ( + <span className="text-muted-foreground text-sm"> + ({user.userCode}) + </span> + )} + </span> + <Check + className={cn( + "h-4 w-4", + selectedUser?.id === user.id + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + {selectedUser && ( + <p className="text-xs text-muted-foreground"> + 선택한 담당자: {selectedUser.name} + {selectedUser.userCode && ` (${selectedUser.userCode})`} + </p> + )} + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isAssigning} + > + 취소 + </Button> + <Button + type="submit" + onClick={handleAssign} + disabled={!selectedUser || itbCodes.length === 0 || isAssigning} + > + {isAssigning ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 지정 중... + </> + ) : ( + "담당자 지정" + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx index 91b2798f..d933fa95 100644 --- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx +++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx @@ -1,308 +1,148 @@ "use client"; import * as React from "react"; -import { type Table } from "@tanstack/react-table"; -import { Download, RefreshCw, Plus, Lock, LockOpen } from "lucide-react"; - +import { Table } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { toast } from "sonner"; +import { Users, RefreshCw, FileDown, Plus } from "lucide-react"; import { RfqsLastView } from "@/db/schema"; -import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; -import { sealMultipleRfqs, unsealMultipleRfqs } from "../service"; - -interface RfqTableToolbarActionsProps { - table: Table<RfqsLastView>; - onRefresh?: () => void; +import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface RfqTableToolbarActionsProps<TData> { + table: Table<TData>; rfqCategory?: "general" | "itb" | "rfq"; + onRefresh?: () => void; } -export function RfqTableToolbarActions({ - table, - onRefresh, +export function RfqTableToolbarActions<TData>({ + table, rfqCategory = "itb", -}: RfqTableToolbarActionsProps) { - const [isExporting, setIsExporting] = React.useState(false); - const [isSealing, setIsSealing] = React.useState(false); - const [sealDialogOpen, setSealDialogOpen] = React.useState(false); - const [sealAction, setSealAction] = React.useState<"seal" | "unseal">("seal"); - + onRefresh +}: RfqTableToolbarActionsProps<TData>) { + const [showAssignDialog, setShowAssignDialog] = React.useState(false); + + // 선택된 행 가져오기 const selectedRows = table.getFilteredSelectedRowModel().rows; - const selectedRfqIds = selectedRows.map(row => row.original.id); - // 선택된 항목들의 밀봉 상태 확인 - const sealedCount = selectedRows.filter(row => row.original.rfqSealedYn).length; - const unsealedCount = selectedRows.filter(row => !row.original.rfqSealedYn).length; - - const handleSealAction = React.useCallback(async (action: "seal" | "unseal") => { - setSealAction(action); - setSealDialogOpen(true); - }, []); - - const confirmSealAction = React.useCallback(async () => { - setIsSealing(true); - try { - const result = sealAction === "seal" - ? await sealMultipleRfqs(selectedRfqIds) - : await unsealMultipleRfqs(selectedRfqIds); - - if (result.success) { - toast.success(result.message); - table.toggleAllRowsSelected(false); // 선택 해제 - onRefresh?.(); // 데이터 새로고침 - } else { - toast.error(result.error); - } - } catch (error) { - toast.error("작업 중 오류가 발생했습니다."); - } finally { - setIsSealing(false); - setSealDialogOpen(false); - } - }, [sealAction, selectedRfqIds, table, onRefresh]); - - const handleExportCSV = React.useCallback(async () => { - setIsExporting(true); - try { - const data = table.getFilteredRowModel().rows.map((row) => { - const original = row.original; - return { - "RFQ 코드": original.rfqCode || "", - "상태": original.status || "", - "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉", - "프로젝트 코드": original.projectCode || "", - "프로젝트명": original.projectName || "", - "자재코드": original.itemCode || "", - "자재명": original.itemName || "", - "패키지 번호": original.packageNo || "", - "패키지명": original.packageName || "", - "구매담당자": original.picUserName || original.picName || "", - "엔지니어링 담당": original.engPicName || "", - "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "", - "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "", - "업체수": original.vendorCount || 0, - "Short List": original.shortListedVendorCount || 0, - "견적접수": original.quotationReceivedCount || 0, - "PR Items": original.prItemsCount || 0, - "주요 Items": original.majorItemsCount || 0, - "시리즈": original.series || "", - "견적 유형": original.rfqType || "", - "견적 제목": original.rfqTitle || "", - "프로젝트 회사": original.projectCompany || "", - "프로젝트 사이트": original.projectSite || "", - "SM 코드": original.smCode || "", - "PR 번호": original.prNumber || "", - "PR 발행일": original.prIssueDate ? new Date(original.prIssueDate).toLocaleDateString("ko-KR") : "", - "생성자": original.createdByUserName || "", - "생성일": original.createdAt ? new Date(original.createdAt).toLocaleDateString("ko-KR") : "", - "수정자": original.updatedByUserName || "", - "수정일": original.updatedAt ? new Date(original.updatedAt).toLocaleDateString("ko-KR") : "", - }; - }); - - const fileName = `RFQ_목록_${new Date().toISOString().split("T")[0]}.csv`; - exportTableToCSV({ data, filename: fileName }); - } catch (error) { - console.error("Export failed:", error); - } finally { - setIsExporting(false); - } - }, [table]); - - const handleExportSelected = React.useCallback(async () => { - setIsExporting(true); - try { - const selectedRows = table.getFilteredSelectedRowModel().rows; - if (selectedRows.length === 0) { - alert("선택된 항목이 없습니다."); - return; - } - - const data = selectedRows.map((row) => { - const original = row.original; - return { - "RFQ 코드": original.rfqCode || "", - "상태": original.status || "", - "밀봉여부": original.rfqSealedYn ? "밀봉" : "미밀봉", - "프로젝트 코드": original.projectCode || "", - "프로젝트명": original.projectName || "", - "자재코드": original.itemCode || "", - "자재명": original.itemName || "", - "패키지 번호": original.packageNo || "", - "패키지명": original.packageName || "", - "구매담당자": original.picUserName || original.picName || "", - "엔지니어링 담당": original.engPicName || "", - "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "", - "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "", - "업체수": original.vendorCount || 0, - "Short List": original.shortListedVendorCount || 0, - "견적접수": original.quotationReceivedCount || 0, - }; - }); - - const fileName = `RFQ_선택항목_${new Date().toISOString().split("T")[0]}.csv`; - exportTableToCSV({ data, filename: fileName }); - } catch (error) { - console.error("Export failed:", error); - } finally { - setIsExporting(false); - } - }, [table]); + // 선택된 RFQ의 ID와 코드 추출 + const selectedRfqData = React.useMemo(() => { + const rows = selectedRows.map(row => row.original as RfqsLastView); + return { + ids: rows.map(row => row.id), + codes: rows.map(row => row.rfqCode || ""), + // "I"로 시작하는 ITB만 필터링 + itbCount: rows.filter(row => row.rfqCode?.startsWith("I")).length, + totalCount: rows.length + }; + }, [selectedRows]); + + // 담당자 지정 가능 여부 체크 ("I"로 시작하는 항목이 있는지) + const canAssignPic = selectedRfqData.itbCount > 0; + + const handleAssignSuccess = () => { + // 테이블 선택 초기화 + table.toggleAllPageRowsSelected(false); + // 데이터 새로고침 + onRefresh?.(); + }; return ( <> <div className="flex items-center gap-2"> - {onRefresh && ( - <Button - variant="outline" - size="sm" - onClick={onRefresh} - className="h-8 px-2 lg:px-3" - > - <RefreshCw className="mr-2 h-4 w-4" /> - 새로고침 - </Button> + {/* 담당자 지정 버튼 - 선택된 항목 중 ITB가 있을 때만 표시 */} + {selectedRfqData.totalCount > 0 && canAssignPic && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="default" + size="sm" + onClick={() => setShowAssignDialog(true)} + className="flex items-center gap-2" + > + <Users className="h-4 w-4" /> + 담당자 지정 + <Badge variant="secondary" className="ml-1"> + {selectedRfqData.itbCount}건 + </Badge> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>선택한 ITB에 구매 담당자를 지정합니다</p> + {selectedRfqData.itbCount !== selectedRfqData.totalCount && ( + <p className="text-xs text-muted-foreground mt-1"> + 전체 {selectedRfqData.totalCount}건 중 ITB {selectedRfqData.itbCount}건만 지정됩니다 + </p> + )} + </TooltipContent> + </Tooltip> + </TooltipProvider> )} - {/* 견적 밀봉/해제 버튼 */} - {selectedRfqIds.length > 0 && ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="h-8 px-2 lg:px-3" - disabled={isSealing} - > - <Lock className="mr-2 h-4 w-4" /> - 견적 밀봉 - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem - onClick={() => handleSealAction("seal")} - disabled={unsealedCount === 0} - > - <Lock className="mr-2 h-4 w-4" /> - 선택 항목 밀봉 ({unsealedCount}개) - </DropdownMenuItem> - <DropdownMenuItem - onClick={() => handleSealAction("unseal")} - disabled={sealedCount === 0} - > - <LockOpen className="mr-2 h-4 w-4" /> - 선택 항목 밀봉 해제 ({sealedCount}개) - </DropdownMenuItem> - <DropdownMenuSeparator /> - <div className="px-2 py-1.5 text-xs text-muted-foreground"> - 전체 {selectedRfqIds.length}개 선택됨 - </div> - </DropdownMenuContent> - </DropdownMenu> + {/* 선택된 항목 표시 */} + {selectedRfqData.totalCount > 0 && ( + <div className="flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md"> + <span className="text-sm text-muted-foreground"> + 선택된 항목: + </span> + <Badge variant="secondary"> + {selectedRfqData.totalCount}건 + </Badge> + {selectedRfqData.totalCount !== selectedRfqData.itbCount && ( + <Badge variant="outline" className="text-xs"> + ITB {selectedRfqData.itbCount}건 + </Badge> + )} + </div> )} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="h-8 px-2 lg:px-3" - disabled={isExporting} - > - <Download className="mr-2 h-4 w-4" /> - {isExporting ? "내보내는 중..." : "내보내기"} - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={handleExportCSV}> - 전체 데이터 내보내기 - </DropdownMenuItem> - <DropdownMenuItem - onClick={handleExportSelected} - disabled={table.getFilteredSelectedRowModel().rows.length === 0} - > - 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개) - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + {/* 기존 버튼들 */} + <Button + variant="outline" + size="sm" + onClick={onRefresh} + className="flex items-center gap-2" + > + <RefreshCw className="h-4 w-4" /> + 새로고침 + </Button> - {/* rfqCategory가 'general'일 때만 일반견적 생성 다이얼로그 표시 */} {rfqCategory === "general" && ( - <CreateGeneralRfqDialog onSuccess={onRefresh} /> + <Button + variant="outline" + size="sm" + className="flex items-center gap-2" + > + <Plus className="h-4 w-4" /> + 일반견적 생성 + </Button> )} + + <Button + variant="outline" + size="sm" + className="flex items-center gap-2" + disabled={selectedRfqData.totalCount === 0} + > + <FileDown className="h-4 w-4" /> + 엑셀 다운로드 + </Button> </div> - {/* 밀봉 확인 다이얼로그 */} - <AlertDialog open={sealDialogOpen} onOpenChange={setSealDialogOpen}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle> - {sealAction === "seal" ? "견적 밀봉 확인" : "견적 밀봉 해제 확인"} - </AlertDialogTitle> - <AlertDialogDescription> - {sealAction === "seal" - ? `선택한 ${unsealedCount}개의 견적을 밀봉하시겠습니까? 밀봉된 견적은 업체에서 수정할 수 없습니다.` - : `선택한 ${sealedCount}개의 견적 밀봉을 해제하시겠습니까? 밀봉이 해제되면 업체에서 견적을 수정할 수 있습니다.`} - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel disabled={isSealing}>취소</AlertDialogCancel> - <AlertDialogAction - onClick={confirmSealAction} - disabled={isSealing} - className={sealAction === "seal" ? "bg-red-600 hover:bg-red-700" : ""} - > - {isSealing ? "처리 중..." : "확인"} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> + {/* 담당자 지정 다이얼로그 */} + <RfqAssignPicDialog + open={showAssignDialog} + onOpenChange={setShowAssignDialog} + selectedRfqIds={selectedRfqData.ids} + selectedRfqCodes={selectedRfqData.codes} + onSuccess={handleAssignSuccess} + /> </> ); -} - -// CSV 내보내기 유틸리티 함수 -function exportTableToCSV({ data, filename }: { data: any[]; filename: string }) { - if (!data || data.length === 0) { - console.warn("No data to export"); - return; - } - - const headers = Object.keys(data[0]); - const csvContent = [ - headers.join(","), - ...data.map(row => - headers.map(header => { - const value = row[header]; - // 값에 쉼표, 줄바꿈, 따옴표가 있으면 따옴표로 감싸기 - if (typeof value === "string" && (value.includes(",") || value.includes("\n") || value.includes('"'))) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; - }).join(",") - ) - ].join("\n"); - - const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = filename; - link.click(); - URL.revokeObjectURL(link.href); }
\ No newline at end of file diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 88ae968a..72539113 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -657,6 +657,102 @@ export function RfqVendorTable({ }, { + accessorKey: "tbeStatus", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="TBE 상태" /> + ), + cell: ({ row }) => { + const status = row.original.tbeStatus; + + if (!status || status === "준비중") { + return ( + <Badge variant="outline" className="text-gray-500"> + <Clock className="h-3 w-3 mr-1" /> + 대기 + </Badge> + ); + } + + const statusConfig = { + "진행중": { variant: "default", icon: <Clock className="h-3 w-3 mr-1" />, color: "text-blue-600" }, + "검토중": { variant: "secondary", icon: <Eye className="h-3 w-3 mr-1" />, color: "text-orange-600" }, + "보류": { variant: "outline", icon: <AlertCircle className="h-3 w-3 mr-1" />, color: "text-yellow-600" }, + "완료": { variant: "success", icon: <CheckCircle className="h-3 w-3 mr-1" />, color: "text-green-600" }, + "취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" />, color: "text-red-600" }, + }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; + + return ( + <Badge variant={statusConfig.variant as any} className={statusConfig.color}> + {statusConfig.icon} + {status} + </Badge> + ); + }, + size: 100, + }, + + { + accessorKey: "tbeEvaluationResult", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="TBE 평가" /> + ), + cell: ({ row }) => { + const result = row.original.tbeEvaluationResult; + const status = row.original.tbeStatus; + + // TBE가 완료되지 않았으면 표시하지 않음 + if (status !== "완료" || !result) { + return <span className="text-xs text-muted-foreground">-</span>; + } + + const resultConfig = { + "Acceptable": { + variant: "success", + icon: <CheckCircle className="h-3 w-3" />, + text: "적합", + color: "bg-green-50 text-green-700 border-green-200" + }, + "Acceptable with Comment": { + variant: "warning", + icon: <AlertCircle className="h-3 w-3" />, + text: "조건부 적합", + color: "bg-yellow-50 text-yellow-700 border-yellow-200" + }, + "Not Acceptable": { + variant: "destructive", + icon: <XCircle className="h-3 w-3" />, + text: "부적합", + color: "bg-red-50 text-red-700 border-red-200" + }, + }[result]; + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger> + <Badge className={cn("text-xs", resultConfig?.color)}> + {resultConfig?.icon} + <span className="ml-1">{resultConfig?.text}</span> + </Badge> + </TooltipTrigger> + <TooltipContent> + <div className="text-xs"> + <p className="font-semibold">{result}</p> + {row.original.conditionalRequirements && ( + <p className="mt-1 text-muted-foreground"> + 조건: {row.original.conditionalRequirements} + </p> + )} + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }, + size: 120, + }, + + { accessorKey: "contractRequirements", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />, cell: ({ row }) => { @@ -785,8 +881,8 @@ export function RfqVendorTable({ // emailSentTo JSON 파싱 let recipients = { to: [], cc: [], sentBy: "" }; try { - if (response?.email?.emailSentTo) { - recipients = JSON.parse(response.email.emailSentTo); + if (response?.emailSentTo) { + recipients = JSON.parse(response.emailSentTo); } } catch (e) { console.error("Failed to parse emailSentTo", e); diff --git a/lib/rfq-last/vendor/send-rfq-dialog.tsx b/lib/rfq-last/vendor/send-rfq-dialog.tsx index ce97dcde..34777864 100644 --- a/lib/rfq-last/vendor/send-rfq-dialog.tsx +++ b/lib/rfq-last/vendor/send-rfq-dialog.tsx @@ -123,13 +123,13 @@ interface Vendor { contactsByPosition?: Record<string, ContactDetail[]>; primaryEmail?: string | null; currency?: string | null; - + // 기본계약 정보 ndaYn?: boolean; generalGtcYn?: boolean; projectGtcYn?: boolean; agreementYn?: boolean; - + // 발송 정보 sendVersion?: number; } @@ -243,15 +243,15 @@ export function SendRfqDialog({ const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({}); const [showResendConfirmDialog, setShowResendConfirmDialog] = React.useState(false); const [resendVendorsInfo, setResendVendorsInfo] = React.useState<{ count: number; names: string[] }>({ count: 0, names: [] }); - + const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false); const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0); const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState(""); const [generatedPdfs, setGeneratedPdfs] = React.useState<Map<string, { buffer: number[], fileName: string }>>(new Map()); - + // 재전송 시 기본계약 스킵 옵션 - 업체별 관리 const [skipContractsForVendor, setSkipContractsForVendor] = React.useState<Record<number, boolean>>({}); - + const generateContractPdf = async ( vendor: VendorWithRecipients, contractType: string, @@ -288,9 +288,9 @@ export function SendRfqDialog({ // 3. PDFtron WebViewer로 PDF 변환 const pdfBuffer = await convertToPdfWithWebViewer(templateFile, templateData); - + const fileName = `${contractType}_${vendor.vendorCode || vendor.vendorId}_${Date.now()}.pdf`; - + return { buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환 fileName @@ -374,7 +374,7 @@ export function SendRfqDialog({ // 초기화 setCustomEmailInputs({}); setShowCustomEmailForm({}); - + // 재전송 업체들의 기본계약 스킵 옵션 초기화 (기본값: false - 재생성) const skipOptions: Record<number, boolean> = {}; selectedVendors.forEach(v => { @@ -511,137 +511,137 @@ export function SendRfqDialog({ const proceedWithSend = React.useCallback(async () => { try { setIsSending(true); - - // 기본계약이 필요한 계약서 목록 수집 - const contractsToGenerate: ContractToGenerate[] = []; - - for (const vendor of vendorsWithRecipients) { - // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기 - const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0; - if (isResendVendor && skipContractsForVendor[vendor.vendorId]) { - continue; // 이 벤더의 계약서 생성을 스킵 - } - - if (vendor.ndaYn) { - contractsToGenerate.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - type: "NDA", - templateName: "비밀" - }); - } - if (vendor.generalGtcYn) { - contractsToGenerate.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - type: "General_GTC", - templateName: "General GTC" - }); - } - if (vendor.projectGtcYn && rfqInfo?.projectCode) { - contractsToGenerate.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - type: "Project_GTC", - templateName: rfqInfo.projectCode - }); - } - if (vendor.agreementYn) { - contractsToGenerate.push({ - vendorId: vendor.vendorId, - vendorName: vendor.vendorName, - type: "기술자료", - templateName: "기술" - }); - } - } - - let pdfsMap = new Map<string, { buffer: number[], fileName: string }>(); - - // PDF 생성이 필요한 경우 - if (contractsToGenerate.length > 0) { - setIsGeneratingPdfs(true); - setPdfGenerationProgress(0); - - try { - let completed = 0; - - for (const contract of contractsToGenerate) { - setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`); - - const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId); - if (!vendor) continue; - - const pdf = await generateContractPdf(vendor, contract.type, contract.templateName); - pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf); - - completed++; - setPdfGenerationProgress((completed / contractsToGenerate.length) * 100); - - await new Promise(resolve => setTimeout(resolve, 100)); + + // 기본계약이 필요한 계약서 목록 수집 + const contractsToGenerate: ContractToGenerate[] = []; + + for (const vendor of vendorsWithRecipients) { + // 재전송 업체이고 해당 업체의 스킵 옵션이 켜져 있으면 계약서 생성 건너뛰기 + const isResendVendor = vendor.sendVersion && vendor.sendVersion > 0; + if (isResendVendor && skipContractsForVendor[vendor.vendorId]) { + continue; // 이 벤더의 계약서 생성을 스킵 + } + + if (vendor.ndaYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "NDA", + templateName: "비밀" + }); + } + if (vendor.generalGtcYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "General_GTC", + templateName: "General GTC" + }); + } + if (vendor.projectGtcYn && rfqInfo?.projectCode) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "Project_GTC", + templateName: rfqInfo.projectCode + }); + } + if (vendor.agreementYn) { + contractsToGenerate.push({ + vendorId: vendor.vendorId, + vendorName: vendor.vendorName, + type: "기술자료", + templateName: "기술" + }); + } } - setGeneratedPdfs(pdfsMap); // UI 업데이트용 + let pdfsMap = new Map<string, { buffer: number[], fileName: string }>(); + + // PDF 생성이 필요한 경우 + if (contractsToGenerate.length > 0) { + setIsGeneratingPdfs(true); + setPdfGenerationProgress(0); + + try { + let completed = 0; + + for (const contract of contractsToGenerate) { + setCurrentGeneratingContract(`${contract.vendorName} - ${contract.type}`); + + const vendor = vendorsWithRecipients.find(v => v.vendorId === contract.vendorId); + if (!vendor) continue; + + const pdf = await generateContractPdf(vendor, contract.type, contract.templateName); + pdfsMap.set(`${contract.vendorId}_${contract.type}_${contract.templateName}`, pdf); + + completed++; + setPdfGenerationProgress((completed / contractsToGenerate.length) * 100); + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + setGeneratedPdfs(pdfsMap); // UI 업데이트용 + } catch (error) { + console.error("PDF 생성 실패:", error); + toast.error("기본계약서 생성에 실패했습니다."); + setIsGeneratingPdfs(false); + setPdfGenerationProgress(0); + return; + } + } + + // RFQ 발송 - pdfsMap을 직접 사용 + setIsGeneratingPdfs(false); + setIsSending(true); + + await onSend({ + vendors: vendorsWithRecipients.map(v => ({ + vendorId: v.vendorId, + vendorName: v.vendorName, + vendorCode: v.vendorCode, + vendorCountry: v.vendorCountry, + selectedMainEmail: v.selectedMainEmail, + additionalEmails: v.additionalEmails, + customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })), + currency: v.currency, + contractRequirements: { + ndaYn: v.ndaYn || false, + generalGtcYn: v.generalGtcYn || false, + projectGtcYn: v.projectGtcYn || false, + agreementYn: v.agreementYn || false, + projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined, + }, + isResend: (v.sendVersion || 0) > 0, + sendVersion: v.sendVersion, + contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId], + })), + attachments: selectedAttachments, + message: additionalMessage, + // 생성된 PDF 데이터 추가 + generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({ + key, + ...data + })), + }); + + toast.success( + `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` + + (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '') + ); + onOpenChange(false); + } catch (error) { - console.error("PDF 생성 실패:", error); - toast.error("기본계약서 생성에 실패했습니다."); + console.error("RFQ 발송 실패:", error); + toast.error("RFQ 발송에 실패했습니다."); + } finally { + setIsSending(false); setIsGeneratingPdfs(false); setPdfGenerationProgress(0); - return; + setCurrentGeneratingContract(""); + setSkipContractsForVendor({}); // 초기화 } - } - - // RFQ 발송 - pdfsMap을 직접 사용 - setIsGeneratingPdfs(false); - setIsSending(true); - - await onSend({ - vendors: vendorsWithRecipients.map(v => ({ - vendorId: v.vendorId, - vendorName: v.vendorName, - vendorCode: v.vendorCode, - vendorCountry: v.vendorCountry, - selectedMainEmail: v.selectedMainEmail, - additionalEmails: v.additionalEmails, - customEmails: v.customEmails.map(c => ({ email: c.email, name: c.name })), - currency: v.currency, - contractRequirements: { - ndaYn: v.ndaYn || false, - generalGtcYn: v.generalGtcYn || false, - projectGtcYn: v.projectGtcYn || false, - agreementYn: v.agreementYn || false, - projectCode: v.projectGtcYn ? rfqInfo?.projectCode : undefined, - }, - isResend: (v.sendVersion || 0) > 0, - sendVersion: v.sendVersion, - contractsSkipped: ((v.sendVersion || 0) > 0) && skipContractsForVendor[v.vendorId], - })), - attachments: selectedAttachments, - message: additionalMessage, - // 생성된 PDF 데이터 추가 - generatedPdfs: Array.from(pdfsMap.entries()).map(([key, data]) => ({ - key, - ...data - })), - }); - - toast.success( - `${vendorsWithRecipients.length}개 업체에 RFQ를 발송했습니다.` + - (contractsToGenerate.length > 0 ? ` ${contractsToGenerate.length}개의 기본계약서가 포함되었습니다.` : '') - ); - onOpenChange(false); - - } catch (error) { - console.error("RFQ 발송 실패:", error); - toast.error("RFQ 발송에 실패했습니다."); - } finally { - setIsSending(false); - setIsGeneratingPdfs(false); - setPdfGenerationProgress(0); - setCurrentGeneratingContract(""); - setSkipContractsForVendor({}); // 초기화 - } -}, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]); + }, [vendorsWithRecipients, selectedAttachments, additionalMessage, onSend, onOpenChange, rfqInfo, skipContractsForVendor]); // 전송 처리 const handleSend = async () => { @@ -712,7 +712,7 @@ export function SendRfqDialog({ <li>업체는 새로운 버전의 견적서를 작성해야 합니다.</li> <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> </ul> - + {/* 기본계약 재발송 정보 */} <div className="mt-3 pt-3 border-t border-yellow-400"> <div className="space-y-2"> @@ -836,8 +836,8 @@ export function SendRfqDialog({ setSkipContractsForVendor(newSkipOptions); }} > - {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" : - Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"} + {Object.values(skipContractsForVendor).every(v => v) ? "전체 재생성" : + Object.values(skipContractsForVendor).every(v => !v) ? "전체 유지" : "전체 유지"} </Button> </TooltipTrigger> <TooltipContent> @@ -993,7 +993,7 @@ export function SendRfqDialog({ [vendor.vendorId]: !checked })); }} - // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" /> <span className="text-xs"> {skipContractsForVendor[vendor.vendorId] ? "유지" : "재생성"} @@ -1002,8 +1002,8 @@ export function SendRfqDialog({ </TooltipTrigger> <TooltipContent> <p className="text-xs"> - {skipContractsForVendor[vendor.vendorId] - ? "기존 계약서를 그대로 유지합니다" + {skipContractsForVendor[vendor.vendorId] + ? "기존 계약서를 그대로 유지합니다" : "기존 계약서를 삭제하고 새로 생성합니다"} </p> </TooltipContent> @@ -1306,9 +1306,9 @@ export function SendRfqDialog({ onChange={(e) => setAdditionalMessage(e.target.value)} /> </div> - - {/* PDF 생성 진행 상황 표시 */} - {isGeneratingPdfs && ( + + {/* PDF 생성 진행 상황 표시 */} + {isGeneratingPdfs && ( <Alert className="border-blue-500 bg-blue-50"> <div className="space-y-3"> <div className="flex items-center gap-2"> @@ -1327,8 +1327,8 @@ export function SendRfqDialog({ </div> </Alert> )} - - + + </div> </div> @@ -1371,7 +1371,7 @@ export function SendRfqDialog({ </Button> </DialogFooter> </DialogContent> - + {/* 재발송 확인 다이얼로그 */} <AlertDialog open={showResendConfirmDialog} onOpenChange={setShowResendConfirmDialog}> <AlertDialogContent className="max-w-2xl"> @@ -1385,7 +1385,7 @@ export function SendRfqDialog({ <p className="text-sm"> <span className="font-semibold text-yellow-700">{resendVendorsInfo.count}개 업체</span>가 재발송 대상입니다. </p> - + {/* 재발송 대상 업체 목록 및 계약서 설정 */} <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3"> <p className="text-sm font-medium text-yellow-800 mb-3">재발송 대상 업체 및 계약서 설정:</p> @@ -1398,7 +1398,7 @@ export function SendRfqDialog({ if (vendor.generalGtcYn) contracts.push("General GTC"); if (vendor.projectGtcYn) contracts.push("Project GTC"); if (vendor.agreementYn) contracts.push("기술자료"); - + return ( <div key={vendor.vendorId} className="flex items-center justify-between p-2 bg-white rounded border border-yellow-100"> <div className="flex items-center gap-3"> @@ -1422,7 +1422,7 @@ export function SendRfqDialog({ [vendor.vendorId]: !checked })); }} - // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" + // className="data-[state=checked]:bg-orange-600 data-[state=checked]:border-orange-600" /> <span className="text-xs text-yellow-800"> {skipContractsForVendor[vendor.vendorId] ? "계약서 유지" : "계약서 재생성"} @@ -1433,43 +1433,43 @@ export function SendRfqDialog({ ); })} </div> - + {/* 전체 선택 버튼 */} - {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 && + {vendorsWithRecipients.some(v => v.sendVersion && v.sendVersion > 0 && (v.ndaYn || v.generalGtcYn || v.projectGtcYn || v.agreementYn)) && ( - <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2"> - <Button - variant="outline" - size="sm" - onClick={() => { - const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); - const newSkipOptions: Record<number, boolean> = {}; - resendVendors.forEach(v => { - newSkipOptions[v.vendorId] = true; // 모두 유지 - }); - setSkipContractsForVendor(newSkipOptions); - }} - > - 전체 계약서 유지 - </Button> - <Button - variant="outline" - size="sm" - onClick={() => { - const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); - const newSkipOptions: Record<number, boolean> = {}; - resendVendors.forEach(v => { - newSkipOptions[v.vendorId] = false; // 모두 재생성 - }); - setSkipContractsForVendor(newSkipOptions); - }} - > - 전체 계약서 재생성 - </Button> - </div> - )} + <div className="mt-3 pt-3 border-t border-yellow-300 flex justify-end gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = true; // 모두 유지 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 유지 + </Button> + <Button + variant="outline" + size="sm" + onClick={() => { + const resendVendors = vendorsWithRecipients.filter(v => v.sendVersion && v.sendVersion > 0); + const newSkipOptions: Record<number, boolean> = {}; + resendVendors.forEach(v => { + newSkipOptions[v.vendorId] = false; // 모두 재생성 + }); + setSkipContractsForVendor(newSkipOptions); + }} + > + 전체 계약서 재생성 + </Button> + </div> + )} </div> - + {/* 경고 메시지 */} <Alert className="border-red-200 bg-red-50"> <AlertCircle className="h-4 w-4 text-red-600" /> @@ -1479,17 +1479,17 @@ export function SendRfqDialog({ <li>기존에 작성된 견적 데이터가 <strong>모두 초기화</strong>됩니다.</li> <li>업체는 처음부터 새로 견적서를 작성해야 합니다.</li> <li>이전에 제출한 견적서는 더 이상 유효하지 않습니다.</li> - {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip && + {Object.entries(skipContractsForVendor).some(([vendorId, skip]) => !skip && vendorsWithRecipients.find(v => v.vendorId === Number(vendorId))) && ( - <li className="text-orange-700 font-medium"> - ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다. - </li> - )} + <li className="text-orange-700 font-medium"> + ⚠️ 선택한 업체의 기존 기본계약서가 <strong>삭제</strong>되고 새로운 계약서가 발송됩니다. + </li> + )} <li>이 작업은 <strong>취소할 수 없습니다</strong>.</li> </ul> </AlertDescription> </Alert> - + <p className="text-sm text-muted-foreground"> 재발송을 진행하시겠습니까? </p> @@ -1503,7 +1503,7 @@ export function SendRfqDialog({ }}> 취소 </AlertDialogCancel> - <AlertDialogAction + <AlertDialogAction onClick={() => { setShowResendConfirmDialog(false); proceedWithSend(); |
