summaryrefslogtreecommitdiff
path: root/lib/bidding/pre-quote
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-07 08:39:04 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-07 08:39:04 +0000
commitba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 (patch)
tree7fb626c184a1fa48b28bf83571dadca2306bd1b5 /lib/bidding/pre-quote
parentb0fe980376fcf1a19ff4b90851ca8b01f378fdc0 (diff)
(최겸) 입찰/견적 수정사항
Diffstat (limited to 'lib/bidding/pre-quote')
-rw-r--r--lib/bidding/pre-quote/service.ts3145
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx224
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx51
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx770
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx125
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx157
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx398
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx311
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx200
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx257
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx130
11 files changed, 1618 insertions, 4150 deletions
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 0f284297..ea92f294 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -1,1528 +1,1619 @@
-'use server'
-
-import db from '@/db/db'
-import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
-import { basicContractTemplates } from '@/db/schema'
-import { vendors } from '@/db/schema/vendors'
-import { users } from '@/db/schema'
-import { sendEmail } from '@/lib/mail/sendEmail'
-import { eq, inArray, and, ilike, sql } from 'drizzle-orm'
-import { mkdir, writeFile } from 'fs/promises'
-import path from 'path'
-import { revalidateTag, revalidatePath } from 'next/cache'
-import { basicContract } from '@/db/schema/basicContractDocumnet'
-import { saveFile } from '@/lib/file-stroage'
-
-// userId를 user.name으로 변환하는 유틸리티 함수
-async function getUserNameById(userId: string): Promise<string> {
- try {
- const user = await db
- .select({ name: users.name })
- .from(users)
- .where(eq(users.id, parseInt(userId)))
- .limit(1)
-
- return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환
- } catch (error) {
- console.error('Failed to get user name:', error)
- return userId // 에러 시 userId를 그대로 반환
- }
-}
-
-interface CreateBiddingCompanyInput {
- biddingId: number
- companyId: number
- contactPerson?: string
- contactEmail?: string
- contactPhone?: string
- notes?: string
-}
-
-interface UpdateBiddingCompanyInput {
- contactPerson?: string
- contactEmail?: string
- contactPhone?: string
- preQuoteAmount?: number
- notes?: string
- invitationStatus?: 'pending' | 'accepted' | 'declined'
- isPreQuoteSelected?: boolean
- isAttendingMeeting?: boolean
-}
-
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-
-
-// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
-export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
- try {
- const result = await db.transaction(async (tx) => {
- // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인
- const existingCompany = await tx
- .select()
- .from(biddingCompanies)
- .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`)
-
- if (existingCompany.length > 0) {
- throw new Error('이미 등록된 업체입니다')
- }
- // 1. biddingCompanies 레코드 생성
- const biddingCompanyResult = await tx.insert(biddingCompanies).values({
- biddingId: input.biddingId,
- companyId: input.companyId,
- invitationStatus: 'pending', // 초기 상태: 입찰생성
- invitedAt: new Date(),
- contactPerson: input.contactPerson,
- contactEmail: input.contactEmail,
- contactPhone: input.contactPhone,
- notes: input.notes,
- }).returning({ id: biddingCompanies.id })
-
- if (biddingCompanyResult.length === 0) {
- throw new Error('업체 추가에 실패했습니다.')
- }
-
- const biddingCompanyId = biddingCompanyResult[0].id
-
- // 2. company_condition_responses 레코드 생성 (기본값으로)
- await tx.insert(companyConditionResponses).values({
- biddingCompanyId: biddingCompanyId,
- // 나머지 필드들은 null로 시작 (벤더가 나중에 응답)
- })
-
- return biddingCompanyId
- })
-
- return {
- success: true,
- message: '업체가 성공적으로 추가되었습니다.',
- data: { id: result }
- }
- } catch (error) {
- console.error('Failed to create bidding company:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
- }
- }
-}
-
-// 사전견적용 업체 정보 업데이트
-export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) {
- try {
- const updateData: any = {
- updatedAt: new Date()
- }
-
- if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson
- if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail
- if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone
- if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount
- if (input.notes !== undefined) updateData.notes = input.notes
- if (input.invitationStatus !== undefined) {
- updateData.invitationStatus = input.invitationStatus
- if (input.invitationStatus !== 'pending') {
- updateData.respondedAt = new Date()
- }
- }
- if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected
- if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting
-
- await db.update(biddingCompanies)
- .set(updateData)
- .where(eq(biddingCompanies.id, id))
-
- return {
- success: true,
- message: '업체 정보가 성공적으로 업데이트되었습니다.',
- }
- } catch (error) {
- console.error('Failed to update bidding company:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.'
- }
- }
-}
-
-// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능)
-export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean) {
- try {
- // 업체들의 입찰 ID 조회 (캐시 무효화를 위해)
- const companies = await db
- .select({ biddingId: biddingCompanies.biddingId })
- .from(biddingCompanies)
- .where(inArray(biddingCompanies.id, companyIds))
- .limit(1)
-
- await db.update(biddingCompanies)
- .set({
- isPreQuoteSelected: isSelected,
- invitationStatus: 'pending', // 초기 상태: 입찰생성
- updatedAt: new Date()
- })
- .where(inArray(biddingCompanies.id, companyIds))
-
- // 캐시 무효화
- if (companies.length > 0) {
- const biddingId = companies[0].biddingId
- revalidateTag(`bidding-${biddingId}`)
- revalidateTag('bidding-detail')
- revalidateTag('quotation-vendors')
- revalidateTag('quotation-details')
- revalidatePath(`/evcp/bid/${biddingId}`)
- }
-
- const message = isSelected
- ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.`
- : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.`
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to update pre-quote selection:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.'
- }
- }
-}
-
-// 사전견적용 업체 삭제
-export async function deleteBiddingCompany(id: number) {
- try {
- // 1. 해당 업체의 초대 상태 확인
- const company = await db
- .select({ invitationStatus: biddingCompanies.invitationStatus })
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, id))
- .then(rows => rows[0])
-
- if (!company) {
- return {
- success: false,
- error: '해당 업체를 찾을 수 없습니다.'
- }
- }
-
- // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가
- if (company.invitationStatus !== 'pending') {
- return {
- success: false,
- error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.'
- }
- }
-
- await db.transaction(async (tx) => {
- // 2. 먼저 관련된 조건 응답들 삭제
- await tx.delete(companyConditionResponses)
- .where(eq(companyConditionResponses.biddingCompanyId, id))
-
- // 3. biddingCompanies 레코드 삭제
- await tx.delete(biddingCompanies)
- .where(eq(biddingCompanies.id, id))
- })
-
- return {
- success: true,
- message: '업체가 성공적으로 삭제되었습니다.'
- }
- } catch (error) {
- console.error('Failed to delete bidding company:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.'
- }
- }
-}
-
-// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인)
-export async function getBiddingCompanies(biddingId: number) {
- try {
- const companies = await db
- .select({
- // bidding_companies 필드들
- id: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- companyId: biddingCompanies.companyId,
- invitationStatus: biddingCompanies.invitationStatus,
- invitedAt: biddingCompanies.invitedAt,
- respondedAt: biddingCompanies.respondedAt,
- preQuoteAmount: biddingCompanies.preQuoteAmount,
- preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
- preQuoteDeadline: biddingCompanies.preQuoteDeadline,
- isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
- isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
- isAttendingMeeting: biddingCompanies.isAttendingMeeting,
- notes: biddingCompanies.notes,
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- contactPhone: biddingCompanies.contactPhone,
- createdAt: biddingCompanies.createdAt,
- updatedAt: biddingCompanies.updatedAt,
-
- // vendors 테이블에서 업체 정보
- companyName: vendors.vendorName,
- companyCode: vendors.vendorCode,
-
- // company_condition_responses 필드들
- paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
- taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
- proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
- priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
- isInitialResponse: companyConditionResponses.isInitialResponse,
- incotermsResponse: companyConditionResponses.incotermsResponse,
- proposedShippingPort: companyConditionResponses.proposedShippingPort,
- proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
- sparePartResponse: companyConditionResponses.sparePartResponse,
- additionalProposals: companyConditionResponses.additionalProposals,
- })
- .from(biddingCompanies)
- .leftJoin(
- vendors,
- eq(biddingCompanies.companyId, vendors.id)
- )
- .leftJoin(
- companyConditionResponses,
- eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
- )
- .where(eq(biddingCompanies.biddingId, biddingId))
-
- return {
- success: true,
- data: companies
- }
- } catch (error) {
- console.error('Failed to get bidding companies:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.'
- }
- }
-}
-
-// 선택된 업체들에게 사전견적 초대 발송
-export async function sendPreQuoteInvitations(companyIds: number[], preQuoteDeadline?: Date | string) {
- try {
- if (companyIds.length === 0) {
- return {
- success: false,
- error: '선택된 업체가 없습니다.'
- }
- }
-
- // 선택된 업체들의 정보와 입찰 정보 조회
- const companiesInfo = await db
- .select({
- biddingCompanyId: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- biddingId: biddingCompanies.biddingId,
- companyName: vendors.vendorName,
- companyEmail: vendors.email,
- // 입찰 정보
- biddingNumber: biddings.biddingNumber,
- revision: biddings.revision,
- projectName: biddings.projectName,
- biddingTitle: biddings.title,
- itemName: biddings.itemName,
- preQuoteDate: biddings.preQuoteDate,
- budget: biddings.budget,
- currency: biddings.currency,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
- .where(inArray(biddingCompanies.id, companyIds))
-
- if (companiesInfo.length === 0) {
- return {
- success: false,
- error: '업체 정보를 찾을 수 없습니다.'
- }
- }
-
- await db.transaction(async (tx) => {
- // 선택된 업체들의 상태를 '사전견적요청(초대발송)'으로 변경
- for (const id of companyIds) {
- await tx.update(biddingCompanies)
- .set({
- invitationStatus: 'sent', // 사전견적 초대 발송 상태
- invitedAt: new Date(),
- preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null,
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, id))
- }
- })
-
- // 각 업체별로 이메일 발송
- for (const company of companiesInfo) {
- if (company.companyEmail) {
- try {
- await sendEmail({
- to: company.companyEmail,
- template: 'pre-quote-invitation',
- context: {
- companyName: company.companyName,
- biddingNumber: company.biddingNumber,
- revision: company.revision,
- projectName: company.projectName,
- biddingTitle: company.biddingTitle,
- itemName: company.itemName,
- preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : null,
- budget: company.budget ? company.budget.toLocaleString() : null,
- currency: company.currency,
- managerName: company.managerName,
- managerEmail: company.managerEmail,
- managerPhone: company.managerPhone,
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`,
- currentYear: new Date().getFullYear(),
- language: 'ko'
- }
- })
- } catch (emailError) {
- console.error(`Failed to send email to ${company.companyEmail}:`, emailError)
- // 이메일 발송 실패해도 전체 프로세스는 계속 진행
- }
- }
- }
- // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만)
- for (const company of companiesInfo) {
- await db.transaction(async (tx) => {
- await tx
- .update(biddings)
- .set({
- status: 'request_for_quotation',
- updatedAt: new Date()
- })
- .where(and(
- eq(biddings.id, company.biddingId),
- eq(biddings.status, 'bidding_generated')
- ))
- })
- }
- return {
- success: true,
- message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.`
- }
- } catch (error) {
- console.error('Failed to send pre-quote invitations:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.'
- }
- }
-}
-
-// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계)
-export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) {
- try {
- // 1. 먼저 입찰 기본 정보를 가져옴
- const biddingResult = await db
- .select({
- id: biddings.id,
- biddingNumber: biddings.biddingNumber,
- revision: biddings.revision,
- projectName: biddings.projectName,
- itemName: biddings.itemName,
- title: biddings.title,
- description: biddings.description,
- content: biddings.content,
- contractType: biddings.contractType,
- biddingType: biddings.biddingType,
- awardCount: biddings.awardCount,
- contractStartDate: biddings.contractStartDate,
- contractEndDate: biddings.contractEndDate,
- preQuoteDate: biddings.preQuoteDate,
- biddingRegistrationDate: biddings.biddingRegistrationDate,
- submissionStartDate: biddings.submissionStartDate,
- submissionEndDate: biddings.submissionEndDate,
- evaluationDate: biddings.evaluationDate,
- currency: biddings.currency,
- budget: biddings.budget,
- targetPrice: biddings.targetPrice,
- status: biddings.status,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
-
- if (biddingResult.length === 0) {
- return null
- }
-
- const biddingData = biddingResult[0]
-
- // 2. 해당 업체의 biddingCompanies 정보 조회
- const companyResult = await db
- .select({
- biddingCompanyId: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- invitationStatus: biddingCompanies.invitationStatus,
- preQuoteAmount: biddingCompanies.preQuoteAmount,
- preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
- preQuoteDeadline: biddingCompanies.preQuoteDeadline,
- isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
- isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
- isAttendingMeeting: biddingCompanies.isAttendingMeeting,
- // company_condition_responses 정보
- paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
- taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
- incotermsResponse: companyConditionResponses.incotermsResponse,
- proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
- proposedShippingPort: companyConditionResponses.proposedShippingPort,
- proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
- priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
- sparePartResponse: companyConditionResponses.sparePartResponse,
- isInitialResponse: companyConditionResponses.isInitialResponse,
- additionalProposals: companyConditionResponses.additionalProposals,
- })
- .from(biddingCompanies)
- .leftJoin(
- companyConditionResponses,
- eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
- )
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.companyId, companyId)
- )
- )
- .limit(1)
-
- // 3. 결과 조합
- if (companyResult.length === 0) {
- // 아직 초대되지 않은 상태
- return {
- ...biddingData,
- biddingCompanyId: null,
- biddingId: biddingData.id,
- invitationStatus: null,
- preQuoteAmount: null,
- preQuoteSubmittedAt: null,
- preQuoteDeadline: null,
- isPreQuoteSelected: false,
- isPreQuoteParticipated: null,
- isAttendingMeeting: null,
- paymentTermsResponse: null,
- taxConditionsResponse: null,
- incotermsResponse: null,
- proposedContractDeliveryDate: null,
- proposedShippingPort: null,
- proposedDestinationPort: null,
- priceAdjustmentResponse: null,
- sparePartResponse: null,
- isInitialResponse: null,
- additionalProposals: null,
- }
- }
-
- const companyData = companyResult[0]
-
- return {
- ...biddingData,
- ...companyData,
- biddingId: biddingData.id, // bidding ID 보장
- }
- } catch (error) {
- console.error('Failed to get bidding companies for partners:', error)
- throw error
- }
-}
-
-// Partners에서 사전견적 응답 제출
-export async function submitPreQuoteResponse(
- biddingCompanyId: number,
- responseData: {
- preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional
- prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가
- paymentTermsResponse?: string
- taxConditionsResponse?: string
- incotermsResponse?: string
- proposedContractDeliveryDate?: string
- proposedShippingPort?: string
- proposedDestinationPort?: string
- priceAdjustmentResponse?: boolean
- isInitialResponse?: boolean
- sparePartResponse?: string
- additionalProposals?: string
- priceAdjustmentForm?: any
- },
- userId: string
-) {
- try {
- let finalAmount = responseData.preQuoteAmount || 0
-
- await db.transaction(async (tx) => {
- // 1. 품목별 견적 정보 최종 저장 (사전견적 제출)
- if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
- // 기존 사전견적 품목 삭제 후 새로 생성
- await tx.delete(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // 품목별 견적 최종 저장
- for (const item of responseData.prItemQuotations) {
- await tx.insert(companyPrItemBids)
- .values({
- biddingCompanyId,
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice.toString(),
- bidAmount: item.bidAmount.toString(),
- proposedDeliveryDate: item.proposedDeliveryDate || null,
- technicalSpecification: item.technicalSpecification || null,
- currency: 'KRW',
- isPreQuote: true,
- submittedAt: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- })
- }
-
- // 총 금액 다시 계산
- finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
- }
-
- // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경)
- await tx.update(biddingCompanies)
- .set({
- preQuoteAmount: finalAmount.toString(),
- preQuoteSubmittedAt: new Date(),
- invitationStatus: 'submitted', // 사전견적 제출 완료 상태로 변경
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- // 3. company_condition_responses 업데이트
- const finalConditionResult = await tx.update(companyConditionResponses)
- .set({
- paymentTermsResponse: responseData.paymentTermsResponse,
- taxConditionsResponse: responseData.taxConditionsResponse,
- incotermsResponse: responseData.incotermsResponse,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
- proposedShippingPort: responseData.proposedShippingPort,
- proposedDestinationPort: responseData.proposedDestinationPort,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse,
- isInitialResponse: responseData.isInitialResponse,
- sparePartResponse: responseData.sparePartResponse,
- additionalProposals: responseData.additionalProposals,
- updatedAt: new Date()
- })
- .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
- .returning()
-
- // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
- if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) {
- const companyConditionResponseId = finalConditionResult[0].id
-
- const priceAdjustmentData = {
- companyConditionResponsesId: companyConditionResponseId,
- itemName: responseData.priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
- comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
- adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
- notes: responseData.priceAdjustmentForm.notes,
- adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
- adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
- nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
- } as any
-
- // 기존 연동제 정보가 있는지 확인
- const existingPriceAdjustment = await tx
- .select()
- .from(priceAdjustmentForms)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- .limit(1)
-
- if (existingPriceAdjustment.length > 0) {
- // 업데이트
- await tx
- .update(priceAdjustmentForms)
- .set(priceAdjustmentData)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- } else {
- // 새로 생성
- await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
- }
- }
-
- // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만)
- // 또한 사전견적 접수일 업데이트
- const biddingCompany = await tx
- .select({ biddingId: biddingCompanies.biddingId })
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, biddingCompanyId))
- .limit(1)
-
- if (biddingCompany.length > 0) {
- await tx
- .update(biddings)
- .set({
- status: 'received_quotation',
- preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트
- updatedAt: new Date()
- })
- .where(and(
- eq(biddings.id, biddingCompany[0].biddingId),
- eq(biddings.status, 'request_for_quotation')
- ))
- }
- })
-
- return {
- success: true,
- message: '사전견적이 성공적으로 제출되었습니다.'
- }
- } catch (error) {
- console.error('Failed to submit pre-quote response:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.'
- }
- }
-}
-
-// Partners에서 사전견적 참여 의사 결정 (수락/거절)
-export async function respondToPreQuoteInvitation(
- biddingCompanyId: number,
- response: 'accepted' | 'declined'
-) {
- try {
- await db.update(biddingCompanies)
- .set({
- invitationStatus: response, // accepted 또는 declined
- respondedAt: new Date(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- const message = response === 'accepted' ?
- '사전견적 참여를 수락했습니다.' :
- '사전견적 참여를 거절했습니다.'
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to respond to pre-quote invitation:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.'
- }
- }
-}
-
-// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용)
-export async function setPreQuoteParticipation(
- biddingCompanyId: number,
- isParticipating: boolean
-) {
- try {
- await db.update(biddingCompanies)
- .set({
- isPreQuoteParticipated: isParticipating,
- respondedAt: new Date(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- const message = isParticipating ?
- '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' :
- '사전견적 참여를 거절했습니다.'
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to set pre-quote participation:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.'
- }
- }
-}
-
-// PR 아이템 조회 (입찰에 포함된 품목들)
-export async function getPrItemsForBidding(biddingId: number) {
- try {
- const prItems = await db
- .select({
- id: prItemsForBidding.id,
- itemNumber: prItemsForBidding.itemNumber,
- prNumber: prItemsForBidding.prNumber,
- itemInfo: prItemsForBidding.itemInfo,
- materialDescription: prItemsForBidding.materialDescription,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- totalWeight: prItemsForBidding.totalWeight,
- weightUnit: prItemsForBidding.weightUnit,
- currency: prItemsForBidding.currency,
- requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
- hasSpecDocument: prItemsForBidding.hasSpecDocument
- })
- .from(prItemsForBidding)
- .where(eq(prItemsForBidding.biddingId, biddingId))
-
- return prItems
- } catch (error) {
- console.error('Failed to get PR items for bidding:', error)
- return []
- }
-}
-
-// SPEC 문서 조회 (PR 아이템에 연결된 문서들)
-export async function getSpecDocumentsForPrItem(prItemId: number) {
- try {
-
- const specDocs = await db
- .select({
- id: biddingDocuments.id,
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- fileSize: biddingDocuments.fileSize,
- filePath: biddingDocuments.filePath,
- title: biddingDocuments.title,
- description: biddingDocuments.description,
- uploadedAt: biddingDocuments.uploadedAt
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.prItemId, prItemId),
- eq(biddingDocuments.documentType, 'spec_document')
- )
- )
-
- return specDocs
- } catch (error) {
- console.error('Failed to get spec documents for PR item:', error)
- return []
- }
-}
-
-// 사전견적 임시저장
-export async function savePreQuoteDraft(
- biddingCompanyId: number,
- responseData: {
- prItemQuotations?: PrItemQuotation[]
- paymentTermsResponse?: string
- taxConditionsResponse?: string
- incotermsResponse?: string
- proposedContractDeliveryDate?: string
- proposedShippingPort?: string
- proposedDestinationPort?: string
- priceAdjustmentResponse?: boolean
- isInitialResponse?: boolean
- sparePartResponse?: string
- additionalProposals?: string
- priceAdjustmentForm?: any
- },
- userId: string
-) {
- try {
- let totalAmount = 0
-
- await db.transaction(async (tx) => {
- // 품목별 견적 정보 저장
- if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
- // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기)
- await tx.delete(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // 새로운 품목별 견적 저장
- for (const item of responseData.prItemQuotations) {
- await tx.insert(companyPrItemBids)
- .values({
- biddingCompanyId,
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice.toString(),
- bidAmount: item.bidAmount.toString(),
- proposedDeliveryDate: item.proposedDeliveryDate || null,
- technicalSpecification: item.technicalSpecification || null,
- currency: 'KRW',
- isPreQuote: true, // 사전견적 표시
- submittedAt: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- })
- }
-
- // 총 금액 계산
- totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
-
- // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음)
- await tx.update(biddingCompanies)
- .set({
- preQuoteAmount: totalAmount.toString(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
- }
-
- // company_condition_responses 업데이트 (임시저장)
- const conditionResult = await tx.update(companyConditionResponses)
- .set({
- paymentTermsResponse: responseData.paymentTermsResponse || null,
- taxConditionsResponse: responseData.taxConditionsResponse || null,
- incotermsResponse: responseData.incotermsResponse || null,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null,
- proposedShippingPort: responseData.proposedShippingPort || null,
- proposedDestinationPort: responseData.proposedDestinationPort || null,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse || null,
- isInitialResponse: responseData.isInitialResponse || null,
- sparePartResponse: responseData.sparePartResponse || null,
- additionalProposals: responseData.additionalProposals || null,
- updatedAt: new Date()
- })
- .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
- .returning()
-
- // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
- if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) {
- const companyConditionResponseId = conditionResult[0].id
-
- const priceAdjustmentData = {
- companyConditionResponsesId: companyConditionResponseId,
- itemName: responseData.priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
- comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
- adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
- notes: responseData.priceAdjustmentForm.notes,
- adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
- adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
- nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
- } as any
-
- // 기존 연동제 정보가 있는지 확인
- const existingPriceAdjustment = await tx
- .select()
- .from(priceAdjustmentForms)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- .limit(1)
-
- if (existingPriceAdjustment.length > 0) {
- // 업데이트
- await tx
- .update(priceAdjustmentForms)
- .set(priceAdjustmentData)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- } else {
- // 새로 생성
- await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
- }
- }
- })
-
- return {
- success: true,
- message: '임시저장이 완료되었습니다.',
- totalAmount
- }
- } catch (error) {
- console.error('Failed to save pre-quote draft:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '임시저장에 실패했습니다.'
- }
- }
-}
-
-// 견적 문서 업로드
-export async function uploadPreQuoteDocument(
- biddingId: number,
- companyId: number,
- file: File,
- userId: string
-) {
- try {
- const userName = await getUserNameById(userId)
- // 파일 저장
- const saveResult = await saveFile({
- file,
- directory: `bidding/${biddingId}/quotations`,
- originalName: file.name,
- userId
- })
-
- if (!saveResult.success) {
- return {
- success: false,
- error: saveResult.error || '파일 저장에 실패했습니다.'
- }
- }
-
- // 데이터베이스에 문서 정보 저장
- const result = await db.insert(biddingDocuments)
- .values({
- biddingId,
- companyId,
- documentType: 'other', // 견적서 타입
- fileName: saveResult.fileName!,
- originalFileName: file.name,
- fileSize: file.size,
- mimeType: file.type,
- filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로)
- title: `견적서 - ${file.name}`,
- description: '협력업체 제출 견적서',
- isPublic: false,
- isRequired: false,
- uploadedBy: userName,
- uploadedAt: new Date()
- })
- .returning()
-
- return {
- success: true,
- message: '견적서가 성공적으로 업로드되었습니다.',
- documentId: result[0].id
- }
- } catch (error) {
- console.error('Failed to upload pre-quote document:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.'
- }
- }
-}
-
-// 업로드된 견적 문서 목록 조회
-export async function getPreQuoteDocuments(biddingId: number, companyId: number) {
- try {
- const documents = await db
- .select({
- id: biddingDocuments.id,
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- fileSize: biddingDocuments.fileSize,
- filePath: biddingDocuments.filePath,
- title: biddingDocuments.title,
- description: biddingDocuments.description,
- uploadedAt: biddingDocuments.uploadedAt,
- uploadedBy: biddingDocuments.uploadedBy
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.biddingId, biddingId),
- eq(biddingDocuments.companyId, companyId),
- )
- )
-
- return documents
- } catch (error) {
- console.error('Failed to get pre-quote documents:', error)
- return []
- }
- }
-
-// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용)
-export async function getSavedPrItemQuotations(biddingCompanyId: number) {
- try {
- const savedQuotations = await db
- .select({
- prItemId: companyPrItemBids.prItemId,
- bidUnitPrice: companyPrItemBids.bidUnitPrice,
- bidAmount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- technicalSpecification: companyPrItemBids.technicalSpecification,
- currency: companyPrItemBids.currency
- })
- .from(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // Decimal 타입을 number로 변환
- return savedQuotations.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: parseFloat(item.bidUnitPrice || '0'),
- bidAmount: parseFloat(item.bidAmount || '0'),
- proposedDeliveryDate: item.proposedDeliveryDate,
- technicalSpecification: item.technicalSpecification,
- currency: item.currency
- }))
- } catch (error) {
- console.error('Failed to get saved PR item quotations:', error)
- return []
- }
- }
-
-// 견적 문서 정보 조회 (다운로드용)
-export async function getPreQuoteDocumentForDownload(
- documentId: number,
- biddingId: number,
- companyId: number
-) {
- try {
- const document = await db
- .select({
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- filePath: biddingDocuments.filePath
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.id, documentId),
- eq(biddingDocuments.biddingId, biddingId),
- eq(biddingDocuments.companyId, companyId),
- eq(biddingDocuments.documentType, 'other')
- )
- )
- .limit(1)
-
- if (document.length === 0) {
- return {
- success: false,
- error: '문서를 찾을 수 없습니다.'
- }
- }
-
- return {
- success: true,
- document: document[0]
- }
- } catch (error) {
- console.error('Failed to get pre-quote document:', error)
- return {
- success: false,
- error: '문서 정보 조회에 실패했습니다.'
- }
- }
-}
-
-// 견적 문서 삭제
-export async function deletePreQuoteDocument(
- documentId: number,
- biddingId: number,
- companyId: number,
- userId: string
-) {
- try {
- // 문서 존재 여부 및 권한 확인
- const document = await db
- .select({
- id: biddingDocuments.id,
- fileName: biddingDocuments.fileName,
- filePath: biddingDocuments.filePath,
- uploadedBy: biddingDocuments.uploadedBy
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.id, documentId),
- eq(biddingDocuments.biddingId, biddingId),
- eq(biddingDocuments.companyId, companyId),
- eq(biddingDocuments.documentType, 'other')
- )
- )
- .limit(1)
-
- if (document.length === 0) {
- return {
- success: false,
- error: '문서를 찾을 수 없습니다.'
- }
- }
-
- const doc = document[0]
-
- // 데이터베이스에서 문서 정보 삭제
- await db
- .delete(biddingDocuments)
- .where(eq(biddingDocuments.id, documentId))
-
- return {
- success: true,
- message: '문서가 성공적으로 삭제되었습니다.'
- }
- } catch (error) {
- console.error('Failed to delete pre-quote document:', error)
- return {
- success: false,
- error: '문서 삭제에 실패했습니다.'
- }
- }
- }
-
-// 기본계약 발송 (서버 액션)
-export async function sendBiddingBasicContracts(
- biddingId: number,
- vendorData: Array<{
- vendorId: number
- vendorName: string
- vendorCode?: string
- vendorCountry?: string
- selectedMainEmail: string
- additionalEmails: string[]
- customEmails?: Array<{ email: string; name?: string }>
- contractRequirements: {
- ndaYn: boolean
- generalGtcYn: boolean
- projectGtcYn: boolean
- agreementYn: boolean
- }
- biddingCompanyId: number
- biddingId: number
- hasExistingContracts?: boolean
- }>,
- generatedPdfs: Array<{
- key: string
- buffer: number[]
- fileName: string
- }>,
- message?: string
-) {
- try {
- console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) });
-
- // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용)
- const [currentUser] = await db.select().from(users).limit(1)
-
- if (!currentUser) {
- throw new Error("사용자 정보를 찾을 수 없습니다.")
- }
-
- const results = []
- const savedContracts = []
-
- // 트랜잭션 시작
- const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
- await mkdir(contractsDir, { recursive: true });
-
- const result = await db.transaction(async (tx) => {
- // 각 벤더별로 기본계약 생성 및 이메일 발송
- for (const vendor of vendorData) {
- // 기존 계약 확인 (biddingCompanyId 기준)
- if (vendor.hasExistingContracts) {
- console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`)
- continue
- }
-
- // 벤더 정보 조회
- const [vendorInfo] = await tx
- .select()
- .from(vendors)
- .where(eq(vendors.id, vendor.vendorId))
- .limit(1)
-
- if (!vendorInfo) {
- console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`)
- continue
- }
-
- // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용)
- console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`)
- let [biddingCompanyInfo] = await tx
- .select()
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, vendor.biddingCompanyId))
- .limit(1)
-
- console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo)
- if (!biddingCompanyInfo) {
- console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`)
- // fallback: biddingId와 vendorId로 찾기 시도
- console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`)
- const [fallbackCompanyInfo] = await tx
- .select()
- .from(biddingCompanies)
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.companyId, vendor.vendorId)
- ))
- .limit(1)
- console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo)
- if (fallbackCompanyInfo) {
- console.log(`Using fallback biddingCompanyInfo`)
- biddingCompanyInfo = fallbackCompanyInfo
- } else {
- console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10))
- continue
- }
- }
-
- // 계약 요구사항에 따라 계약서 생성
- const contractTypes: Array<{ type: string; templateName: string }> = []
- if (vendor.contractRequirements.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' })
- if (vendor.contractRequirements.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' })
- if (vendor.contractRequirements.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' })
- if (vendor.contractRequirements.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' })
- console.log("contractTypes", contractTypes)
- for (const contractType of contractTypes) {
- // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기)
- console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key))
- const pdfData = generatedPdfs.find((pdf: any) =>
- pdf.key.includes(`${vendor.vendorId}_`) &&
- pdf.key.includes(`_${contractType.templateName}`)
- )
- console.log("pdfData", pdfData, "for contractType", contractType)
- if (!pdfData) {
- console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`)
- continue
- }
-
- // 파일 저장 (rfq-last 방식)
- const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
- const filePath = path.join(contractsDir, fileName);
-
- await writeFile(filePath, Buffer.from(pdfData.buffer));
-
- // 템플릿 정보 조회 (rfq-last 방식)
- const [template] = await db
- .select()
- .from(basicContractTemplates)
- .where(
- and(
- ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`),
- eq(basicContractTemplates.status, "ACTIVE")
- )
- )
- .limit(1);
-
- console.log("템플릿", contractType.templateName, template);
-
- // 기존 계약이 있는지 확인 (rfq-last 방식)
- const [existingContract] = await tx
- .select()
- .from(basicContract)
- .where(
- and(
- eq(basicContract.templateId, template?.id),
- eq(basicContract.vendorId, vendor.vendorId),
- eq(basicContract.biddingCompanyId, biddingCompanyInfo.id)
- )
- )
- .limit(1);
-
- let contractRecord;
-
- if (existingContract) {
- // 기존 계약이 있으면 업데이트
- [contractRecord] = await tx
- .update(basicContract)
- .set({
- requestedBy: currentUser.id,
- status: "PENDING", // 재발송 상태
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
- updatedAt: new Date(),
- })
- .where(eq(basicContract.id, existingContract.id))
- .returning();
-
- console.log("기존 계약 업데이트:", contractRecord.id);
- } else {
- // 새 계약 생성
- [contractRecord] = await tx
- .insert(basicContract)
- .values({
- templateId: template?.id || null,
- vendorId: vendor.vendorId,
- biddingCompanyId: biddingCompanyInfo.id,
- rfqCompanyId: null,
- generalContractId: null,
- requestedBy: currentUser.id,
- status: 'PENDING',
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
-
- console.log("새 계약 생성:", contractRecord.id);
- }
-
- results.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- contractId: contractRecord.id,
- contractType: contractType.type,
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
- })
-
- // savedContracts에 추가 (rfq-last 방식)
- // savedContracts.push({
- // vendorId: vendor.vendorId,
- // vendorName: vendor.vendorName,
- // templateName: contractType.templateName,
- // contractId: contractRecord.id,
- // fileName: fileName,
- // isUpdated: !!existingContract, // 업데이트 여부 표시
- // })
- }
-
- // 이메일 발송 (선택사항)
- if (vendor.selectedMainEmail) {
- try {
- await sendEmail({
- to: vendor.selectedMainEmail,
- template: 'basic-contract-notification',
- context: {
- vendorName: vendor.vendorName,
- biddingId: biddingId,
- contractCount: contractTypes.length,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'),
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`,
- message: message || '',
- currentYear: new Date().getFullYear(),
- language: 'ko'
- }
- })
- } catch (emailError) {
- console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError)
- // 이메일 발송 실패해도 계약 생성은 유지
- }
- }
- }
-
- return {
- success: true,
- message: `${results.length}개의 기본계약이 생성되었습니다.`,
- results,
- savedContracts,
- totalContracts: savedContracts.length,
- }
- })
-
- return result
-
- } catch (error) {
- console.error('기본계약 발송 실패:', error)
- throw new Error(
- error instanceof Error
- ? error.message
- : '기본계약 발송 중 오류가 발생했습니다.'
- )
- }
-}
-
-// 기존 기본계약 조회 (서버 액션)
-export async function getExistingBasicContractsForBidding(biddingId: number) {
- try {
- // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회
- const existingContracts = await db
- .select({
- id: basicContract.id,
- vendorId: basicContract.vendorId,
- biddingCompanyId: basicContract.biddingCompanyId,
- biddingId: biddingCompanies.biddingId,
- templateId: basicContract.templateId,
- status: basicContract.status,
- createdAt: basicContract.createdAt,
- })
- .from(basicContract)
- .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id))
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- )
- )
-
- return {
- success: true,
- contracts: existingContracts
- }
-
- } catch (error) {
- console.error('기존 계약 조회 실패:', error)
- return {
- success: false,
- error: '기존 계약 조회에 실패했습니다.'
- }
- }
-}
-
-// 선정된 업체들 조회 (서버 액션)
-export async function getSelectedVendorsForBidding(biddingId: number) {
- try {
- const selectedCompanies = await db
- .select({
- id: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- companyName: vendors.vendorName,
- companyCode: vendors.vendorCode,
- companyCountry: vendors.country,
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- biddingId: biddingCompanies.biddingId,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true)
- ))
-
- return {
- success: true,
- vendors: selectedCompanies.map(company => ({
- vendorId: company.companyId, // 실제 vendor ID
- vendorName: company.companyName || '',
- vendorCode: company.companyCode,
- vendorCountry: company.companyCountry || '대한민국',
- contactPerson: company.contactPerson,
- contactEmail: company.contactEmail,
- biddingCompanyId: company.id, // biddingCompany ID
- biddingId: company.biddingId,
- ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
- generalGtcYn: true,
- projectGtcYn: true,
- agreementYn: true
- }))
- }
- } catch (error) {
- console.error('선정된 업체 조회 실패:', error)
- return {
- success: false,
- error: '선정된 업체 조회에 실패했습니다.',
- vendors: []
- }
- }
+'use server'
+
+import db from '@/db/db'
+import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
+import { basicContractTemplates } from '@/db/schema'
+import { vendors } from '@/db/schema/vendors'
+import { users } from '@/db/schema'
+import { sendEmail } from '@/lib/mail/sendEmail'
+import { eq, inArray, and, ilike, sql } from 'drizzle-orm'
+import { mkdir, writeFile } from 'fs/promises'
+import path from 'path'
+import { revalidateTag, revalidatePath } from 'next/cache'
+import { basicContract } from '@/db/schema/basicContractDocumnet'
+import { saveFile } from '@/lib/file-stroage'
+
+// userId를 user.name으로 변환하는 유틸리티 함수
+async function getUserNameById(userId: string): Promise<string> {
+ try {
+ const user = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1)
+
+ return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId // 에러 시 userId를 그대로 반환
+ }
+}
+
+interface CreateBiddingCompanyInput {
+ biddingId: number
+ companyId: number
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ notes?: string
+}
+
+interface UpdateBiddingCompanyInput {
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ preQuoteAmount?: number
+ notes?: string
+ invitationStatus?: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
+ isPreQuoteSelected?: boolean
+ isAttendingMeeting?: boolean
+}
+
+interface PrItemQuotation {
+ prItemId: number
+ bidUnitPrice: number
+ bidAmount: number
+ proposedDeliveryDate?: string
+ technicalSpecification?: string
+}
+
+
+
+ // 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
+export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`)
+
+ if (existingCompany.length > 0) {
+ throw new Error('이미 등록된 업체입니다')
+ }
+ // 1. biddingCompanies 레코드 생성
+ const biddingCompanyResult = await tx.insert(biddingCompanies).values({
+ biddingId: input.biddingId,
+ companyId: input.companyId,
+ invitationStatus: 'pending', // 초기 상태: 초대 대기
+ invitedAt: new Date(),
+ contactPerson: input.contactPerson,
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone,
+ notes: input.notes,
+ }).returning({ id: biddingCompanies.id })
+
+ if (biddingCompanyResult.length === 0) {
+ throw new Error('업체 추가에 실패했습니다.')
+ }
+
+ const biddingCompanyId = biddingCompanyResult[0].id
+
+ // 2. company_condition_responses 레코드 생성 (기본값으로)
+ await tx.insert(companyConditionResponses).values({
+ biddingCompanyId: biddingCompanyId,
+ // 나머지 필드들은 null로 시작 (벤더가 나중에 응답)
+ })
+
+ return biddingCompanyId
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 추가되었습니다.',
+ data: { id: result }
+ }
+ } catch (error) {
+ console.error('Failed to create bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
+ }
+ }
+}
+
+// 사전견적용 업체 정보 업데이트
+export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) {
+ try {
+ const updateData: any = {
+ updatedAt: new Date()
+ }
+
+ if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson
+ if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail
+ if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone
+ if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount
+ if (input.notes !== undefined) updateData.notes = input.notes
+ if (input.invitationStatus !== undefined) {
+ updateData.invitationStatus = input.invitationStatus
+ if (input.invitationStatus !== 'pending') {
+ updateData.respondedAt = new Date()
+ }
+ }
+ if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected
+ if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting
+
+ await db.update(biddingCompanies)
+ .set(updateData)
+ .where(eq(biddingCompanies.id, id))
+
+ return {
+ success: true,
+ message: '업체 정보가 성공적으로 업데이트되었습니다.',
+ }
+ } catch (error) {
+ console.error('Failed to update bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능)
+export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean) {
+ try {
+ // 업체들의 입찰 ID 조회 (캐시 무효화를 위해)
+ const companies = await db
+ .select({ biddingId: biddingCompanies.biddingId })
+ .from(biddingCompanies)
+ .where(inArray(biddingCompanies.id, companyIds))
+ .limit(1)
+
+ await db.update(biddingCompanies)
+ .set({
+ isPreQuoteSelected: isSelected,
+ invitationStatus: 'pending', // 초기 상태: 초대 대기
+ updatedAt: new Date()
+ })
+ .where(inArray(biddingCompanies.id, companyIds))
+
+ // 캐시 무효화
+ if (companies.length > 0) {
+ const biddingId = companies[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-detail')
+ revalidateTag('quotation-vendors')
+ revalidateTag('quotation-details')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
+ const message = isSelected
+ ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.`
+ : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.`
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to update pre-quote selection:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 사전견적용 업체 삭제
+export async function deleteBiddingCompany(id: number) {
+ try {
+ // 1. 해당 업체의 초대 상태 확인
+ const company = await db
+ .select({ invitationStatus: biddingCompanies.invitationStatus })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, id))
+ .then(rows => rows[0])
+
+ if (!company) {
+ return {
+ success: false,
+ error: '해당 업체를 찾을 수 없습니다.'
+ }
+ }
+
+ // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가
+ if (company.invitationStatus !== 'pending') {
+ return {
+ success: false,
+ error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.'
+ }
+ }
+
+ await db.transaction(async (tx) => {
+ // 2. 먼저 관련된 조건 응답들 삭제
+ await tx.delete(companyConditionResponses)
+ .where(eq(companyConditionResponses.biddingCompanyId, id))
+
+ // 3. biddingCompanies 레코드 삭제
+ await tx.delete(biddingCompanies)
+ .where(eq(biddingCompanies.id, id))
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인)
+export async function getBiddingCompanies(biddingId: number) {
+ try {
+ const companies = await db
+ .select({
+ // bidding_companies 필드들
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ companyId: biddingCompanies.companyId,
+ invitationStatus: biddingCompanies.invitationStatus,
+ invitedAt: biddingCompanies.invitedAt,
+ respondedAt: biddingCompanies.respondedAt,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ preQuoteDeadline: biddingCompanies.preQuoteDeadline,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
+ notes: biddingCompanies.notes,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ contactPhone: biddingCompanies.contactPhone,
+ createdAt: biddingCompanies.createdAt,
+ updatedAt: biddingCompanies.updatedAt,
+
+ // vendors 테이블에서 업체 정보
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+ companyEmail: vendors.email, // 벤더의 기본 이메일
+
+ // company_condition_responses 필드들
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
+ additionalProposals: companyConditionResponses.additionalProposals,
+ })
+ .from(biddingCompanies)
+ .leftJoin(
+ vendors,
+ eq(biddingCompanies.companyId, vendors.id)
+ )
+ .leftJoin(
+ companyConditionResponses,
+ eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
+ )
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ // 디버깅: 서버에서 가져온 데이터 확인
+ console.log('=== getBiddingCompanies Server Log ===')
+ console.log('Total companies:', companies.length)
+ if (companies.length > 0) {
+ console.log('First company:', {
+ companyName: companies[0].companyName,
+ companyEmail: companies[0].companyEmail,
+ companyCode: companies[0].companyCode,
+ companyId: companies[0].companyId
+ })
+ }
+ console.log('======================================')
+
+ return {
+ success: true,
+ data: companies
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선택된 업체들에게 사전견적 초대 발송
+interface CompanyWithContacts {
+ id: number
+ companyId: number
+ companyName: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+}
+
+export async function sendPreQuoteInvitations(companiesData: CompanyWithContacts[], preQuoteDeadline?: Date | string) {
+ try {
+ console.log('=== sendPreQuoteInvitations called ===');
+ console.log('companiesData:', JSON.stringify(companiesData, null, 2));
+
+ if (companiesData.length === 0) {
+ return {
+ success: false,
+ error: '선택된 업체가 없습니다.'
+ }
+ }
+
+ const companyIds = companiesData.map(c => c.id);
+ console.log('companyIds:', companyIds);
+
+ // 선택된 업체들의 정보와 입찰 정보 조회
+ const companiesInfo = await db
+ .select({
+ biddingCompanyId: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ biddingId: biddingCompanies.biddingId,
+ companyName: vendors.vendorName,
+ companyEmail: vendors.email,
+ // 입찰 정보
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ biddingTitle: biddings.title,
+ itemName: biddings.itemName,
+ preQuoteDate: biddings.preQuoteDate,
+ budget: biddings.budget,
+ currency: biddings.currency,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
+ .where(inArray(biddingCompanies.id, companyIds))
+
+ console.log('companiesInfo fetched:', JSON.stringify(companiesInfo, null, 2));
+
+ if (companiesInfo.length === 0) {
+ return {
+ success: false,
+ error: '업체 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 모든 필드가 null이 아닌지 확인하고 안전하게 변환
+ const safeCompaniesInfo = companiesInfo.map(company => ({
+ ...company,
+ companyName: company.companyName ?? '',
+ companyEmail: company.companyEmail ?? '',
+ biddingNumber: company.biddingNumber ?? '',
+ revision: company.revision ?? '',
+ projectName: company.projectName ?? '',
+ biddingTitle: company.biddingTitle ?? '',
+ itemName: company.itemName ?? '',
+ preQuoteDate: company.preQuoteDate ?? null,
+ budget: company.budget ?? null,
+ currency: company.currency ?? '',
+ bidPicName: company.bidPicName ?? '',
+ supplyPicName: company.supplyPicName ?? '',
+ }));
+
+ console.log('safeCompaniesInfo prepared:', JSON.stringify(safeCompaniesInfo, null, 2));
+
+ await db.transaction(async (tx) => {
+ // 선택된 업체들의 상태를 '사전견적 초대 발송'으로 변경
+ for (const id of companyIds) {
+ await tx.update(biddingCompanies)
+ .set({
+ invitationStatus: 'pre_quote_sent', // 사전견적 초대 발송 상태
+ invitedAt: new Date(),
+ preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null,
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, id))
+ }
+ })
+
+ // 각 업체별로 이메일 발송 (담당자 정보 포함)
+ console.log('=== Starting email sending ===');
+ for (const company of safeCompaniesInfo) {
+ console.log(`Processing company: ${company.companyName} (biddingCompanyId: ${company.biddingCompanyId})`);
+
+ const companyData = companiesData.find(c => c.id === company.biddingCompanyId);
+ if (!companyData) {
+ console.log(`No companyData found for biddingCompanyId: ${company.biddingCompanyId}`);
+ continue;
+ }
+
+ console.log('companyData found:', JSON.stringify(companyData, null, 2));
+
+ const mainEmail = companyData.selectedMainEmail || '';
+ const ccEmails = Array.isArray(companyData.additionalEmails) ? companyData.additionalEmails : [];
+
+ console.log(`mainEmail: ${mainEmail}, ccEmails: ${JSON.stringify(ccEmails)}`);
+
+ if (mainEmail) {
+ try {
+ console.log('Preparing to send email...');
+
+ const emailContext = {
+ companyName: company.companyName,
+ biddingNumber: company.biddingNumber,
+ revision: company.revision,
+ projectName: company.projectName,
+ biddingTitle: company.biddingTitle,
+ itemName: company.itemName,
+ preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : '',
+ budget: company.budget ? String(company.budget) : '',
+ currency: company.currency,
+ bidPicName: company.bidPicName,
+ supplyPicName: company.supplyPicName,
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`,
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ };
+
+ console.log('Email context prepared:', JSON.stringify(emailContext, null, 2));
+
+ await sendEmail({
+ to: mainEmail,
+ cc: ccEmails.length > 0 ? ccEmails : undefined,
+ template: 'pre-quote-invitation',
+ context: emailContext
+ })
+
+ console.log(`Email sent successfully to ${mainEmail}`);
+ } catch (emailError) {
+ console.error(`Failed to send email to ${mainEmail}:`, emailError)
+ // 이메일 발송 실패해도 전체 프로세스는 계속 진행
+ }
+ }
+ }
+ // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만)
+ for (const company of companiesInfo) {
+ await db.transaction(async (tx) => {
+ await tx
+ .update(biddings)
+ .set({
+ status: 'request_for_quotation',
+ updatedAt: new Date()
+ })
+ .where(and(
+ eq(biddings.id, company.biddingId),
+ eq(biddings.status, 'bidding_generated')
+ ))
+ })
+ }
+ return {
+ success: true,
+ message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.`
+ }
+ } catch (error) {
+ console.error('Failed to send pre-quote invitations:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.'
+ }
+ }
+}
+
+// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계)
+export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) {
+ try {
+ // 1. 먼저 입찰 기본 정보를 가져옴
+ const biddingResult = await db
+ .select({
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ title: biddings.title,
+ description: biddings.description,
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
+ preQuoteDate: biddings.preQuoteDate,
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ evaluationDate: biddings.evaluationDate,
+ currency: biddings.currency,
+ budget: biddings.budget,
+ targetPrice: biddings.targetPrice,
+ status: biddings.status,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (biddingResult.length === 0) {
+ return null
+ }
+
+ const biddingData = biddingResult[0]
+
+ // 2. 해당 업체의 biddingCompanies 정보 조회
+ const companyResult = await db
+ .select({
+ biddingCompanyId: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ invitationStatus: biddingCompanies.invitationStatus,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ preQuoteDeadline: biddingCompanies.preQuoteDeadline,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
+ // company_condition_responses 정보
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
+ additionalProposals: companyConditionResponses.additionalProposals,
+ })
+ .from(biddingCompanies)
+ .leftJoin(
+ companyConditionResponses,
+ eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
+ )
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, companyId)
+ )
+ )
+ .limit(1)
+
+ // 3. 결과 조합
+ if (companyResult.length === 0) {
+ // 아직 초대되지 않은 상태
+ return {
+ ...biddingData,
+ biddingCompanyId: null,
+ biddingId: biddingData.id,
+ invitationStatus: null,
+ preQuoteAmount: null,
+ preQuoteSubmittedAt: null,
+ preQuoteDeadline: null,
+ isPreQuoteSelected: false,
+ isPreQuoteParticipated: null,
+ isAttendingMeeting: null,
+ paymentTermsResponse: null,
+ taxConditionsResponse: null,
+ incotermsResponse: null,
+ proposedContractDeliveryDate: null,
+ proposedShippingPort: null,
+ proposedDestinationPort: null,
+ priceAdjustmentResponse: null,
+ sparePartResponse: null,
+ isInitialResponse: null,
+ additionalProposals: null,
+ }
+ }
+
+ const companyData = companyResult[0]
+
+ return {
+ ...biddingData,
+ ...companyData,
+ biddingId: biddingData.id, // bidding ID 보장
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies for partners:', error)
+ throw error
+ }
+}
+
+// Partners에서 사전견적 응답 제출
+export async function submitPreQuoteResponse(
+ biddingCompanyId: number,
+ responseData: {
+ preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional
+ prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가
+ paymentTermsResponse?: string
+ taxConditionsResponse?: string
+ incotermsResponse?: string
+ proposedContractDeliveryDate?: string
+ proposedShippingPort?: string
+ proposedDestinationPort?: string
+ priceAdjustmentResponse?: boolean
+ isInitialResponse?: boolean
+ sparePartResponse?: string
+ additionalProposals?: string
+ priceAdjustmentForm?: any
+ },
+ userId: string
+) {
+ try {
+ let finalAmount = responseData.preQuoteAmount || 0
+
+ await db.transaction(async (tx) => {
+ // 1. 품목별 견적 정보 최종 저장 (사전견적 제출)
+ if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
+ // 기존 사전견적 품목 삭제 후 새로 생성
+ await tx.delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // 품목별 견적 최종 저장
+ for (const item of responseData.prItemQuotations) {
+ await tx.insert(companyPrItemBids)
+ .values({
+ biddingCompanyId,
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice.toString(),
+ bidAmount: item.bidAmount.toString(),
+ proposedDeliveryDate: item.proposedDeliveryDate || null,
+ technicalSpecification: item.technicalSpecification || null,
+ currency: 'KRW',
+ isPreQuote: true,
+ submittedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+
+ // 총 금액 다시 계산
+ finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
+ }
+
+ // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경)
+ await tx.update(biddingCompanies)
+ .set({
+ preQuoteAmount: finalAmount.toString(),
+ preQuoteSubmittedAt: new Date(),
+ invitationStatus: 'pre_quote_submitted', // 사전견적제출완료 상태로 변경
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ // 3. company_condition_responses 업데이트
+ const finalConditionResult = await tx.update(companyConditionResponses)
+ .set({
+ paymentTermsResponse: responseData.paymentTermsResponse,
+ taxConditionsResponse: responseData.taxConditionsResponse,
+ incotermsResponse: responseData.incotermsResponse,
+ proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
+ proposedShippingPort: responseData.proposedShippingPort,
+ proposedDestinationPort: responseData.proposedDestinationPort,
+ priceAdjustmentResponse: responseData.priceAdjustmentResponse,
+ isInitialResponse: responseData.isInitialResponse,
+ sparePartResponse: responseData.sparePartResponse,
+ additionalProposals: responseData.additionalProposals,
+ updatedAt: new Date()
+ })
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .returning()
+
+ // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
+ if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) {
+ const companyConditionResponseId = finalConditionResult[0].id
+
+ const priceAdjustmentData = {
+ companyConditionResponsesId: companyConditionResponseId,
+ itemName: responseData.priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ } as any
+
+ // 기존 연동제 정보가 있는지 확인
+ const existingPriceAdjustment = await tx
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ if (existingPriceAdjustment.length > 0) {
+ // 업데이트
+ await tx
+ .update(priceAdjustmentForms)
+ .set(priceAdjustmentData)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ } else {
+ // 새로 생성
+ await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
+ }
+ }
+
+ // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만)
+ // 또한 사전견적 접수일 업데이트
+ const biddingCompany = await tx
+ .select({ biddingId: biddingCompanies.biddingId })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ .limit(1)
+
+ if (biddingCompany.length > 0) {
+ await tx
+ .update(biddings)
+ .set({
+ status: 'received_quotation',
+ preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트
+ updatedAt: new Date()
+ })
+ .where(and(
+ eq(biddings.id, biddingCompany[0].biddingId),
+ eq(biddings.status, 'request_for_quotation')
+ ))
+ }
+ })
+
+ return {
+ success: true,
+ message: '사전견적이 성공적으로 제출되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to submit pre-quote response:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.'
+ }
+ }
+}
+
+// Partners에서 사전견적 참여 의사 결정 (참여/미참여)
+export async function respondToPreQuoteInvitation(
+ biddingCompanyId: number,
+ response: 'pre_quote_accepted' | 'pre_quote_declined'
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ invitationStatus: response, // pre_quote_accepted 또는 pre_quote_declined
+ respondedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ const message = response === 'pre_quote_accepted' ?
+ '사전견적 참여를 수락했습니다.' :
+ '사전견적 참여를 거절했습니다.'
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to respond to pre-quote invitation:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.'
+ }
+ }
+}
+
+// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용)
+export async function setPreQuoteParticipation(
+ biddingCompanyId: number,
+ isParticipating: boolean
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ isPreQuoteParticipated: isParticipating,
+ respondedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ const message = isParticipating ?
+ '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' :
+ '사전견적 참여를 거절했습니다.'
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to set pre-quote participation:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.'
+ }
+ }
+}
+
+// PR 아이템 조회 (입찰에 포함된 품목들)
+export async function getPrItemsForBidding(biddingId: number) {
+ try {
+ const prItems = await db
+ .select({
+ id: prItemsForBidding.id,
+ biddingId: prItemsForBidding.biddingId,
+ itemNumber: prItemsForBidding.itemNumber,
+ projectId: prItemsForBidding.projectId,
+ projectInfo: prItemsForBidding.projectInfo,
+ itemInfo: prItemsForBidding.itemInfo,
+ shi: prItemsForBidding.shi,
+ materialGroupNumber: prItemsForBidding.materialGroupNumber,
+ materialGroupInfo: prItemsForBidding.materialGroupInfo,
+ materialNumber: prItemsForBidding.materialNumber,
+ materialInfo: prItemsForBidding.materialInfo,
+ requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
+ annualUnitPrice: prItemsForBidding.annualUnitPrice,
+ currency: prItemsForBidding.currency,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ totalWeight: prItemsForBidding.totalWeight,
+ weightUnit: prItemsForBidding.weightUnit,
+ priceUnit: prItemsForBidding.priceUnit,
+ purchaseUnit: prItemsForBidding.purchaseUnit,
+ materialWeight: prItemsForBidding.materialWeight,
+ prNumber: prItemsForBidding.prNumber,
+ hasSpecDocument: prItemsForBidding.hasSpecDocument,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ return prItems
+ } catch (error) {
+ console.error('Failed to get PR items for bidding:', error)
+ return []
+ }
+}
+
+// SPEC 문서 조회 (PR 아이템에 연결된 문서들)
+export async function getSpecDocumentsForPrItem(prItemId: number) {
+ try {
+
+ const specDocs = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.prItemId, prItemId),
+ eq(biddingDocuments.documentType, 'spec_document')
+ )
+ )
+
+ return specDocs
+ } catch (error) {
+ console.error('Failed to get spec documents for PR item:', error)
+ return []
+ }
+}
+
+// 사전견적 임시저장
+export async function savePreQuoteDraft(
+ biddingCompanyId: number,
+ responseData: {
+ prItemQuotations?: PrItemQuotation[]
+ paymentTermsResponse?: string
+ taxConditionsResponse?: string
+ incotermsResponse?: string
+ proposedContractDeliveryDate?: string
+ proposedShippingPort?: string
+ proposedDestinationPort?: string
+ priceAdjustmentResponse?: boolean
+ isInitialResponse?: boolean
+ sparePartResponse?: string
+ additionalProposals?: string
+ priceAdjustmentForm?: any
+ },
+ userId: string
+) {
+ try {
+ let totalAmount = 0
+ console.log('responseData', responseData)
+
+ await db.transaction(async (tx) => {
+ // 품목별 견적 정보 저장
+ if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
+ // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기)
+ await tx.delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // 새로운 품목별 견적 저장
+ for (const item of responseData.prItemQuotations) {
+ await tx.insert(companyPrItemBids)
+ .values({
+ biddingCompanyId,
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice.toString(),
+ bidAmount: item.bidAmount.toString(),
+ proposedDeliveryDate: item.proposedDeliveryDate || null,
+ technicalSpecification: item.technicalSpecification || null,
+ currency: 'KRW',
+ isPreQuote: true, // 사전견적 표시
+ submittedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+
+ // 총 금액 계산
+ totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
+
+ // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음)
+ await tx.update(biddingCompanies)
+ .set({
+ preQuoteAmount: totalAmount.toString(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ }
+
+ // company_condition_responses 업데이트 (임시저장)
+ const conditionResult = await tx.update(companyConditionResponses)
+ .set({
+ paymentTermsResponse: responseData.paymentTermsResponse || null,
+ taxConditionsResponse: responseData.taxConditionsResponse || null,
+ incotermsResponse: responseData.incotermsResponse || null,
+ proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null,
+ proposedShippingPort: responseData.proposedShippingPort || null,
+ proposedDestinationPort: responseData.proposedDestinationPort || null,
+ priceAdjustmentResponse: responseData.priceAdjustmentResponse || null,
+ isInitialResponse: responseData.isInitialResponse || null,
+ sparePartResponse: responseData.sparePartResponse || null,
+ additionalProposals: responseData.additionalProposals || null,
+ updatedAt: new Date()
+ })
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .returning()
+
+ // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
+ if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) {
+ const companyConditionResponseId = conditionResult[0].id
+
+ const priceAdjustmentData = {
+ companyConditionResponsesId: companyConditionResponseId,
+ itemName: responseData.priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ } as any
+
+ // 기존 연동제 정보가 있는지 확인
+ const existingPriceAdjustment = await tx
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ if (existingPriceAdjustment.length > 0) {
+ // 업데이트
+ await tx
+ .update(priceAdjustmentForms)
+ .set(priceAdjustmentData)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ } else {
+ // 새로 생성
+ await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
+ }
+ }
+ })
+
+ return {
+ success: true,
+ message: '임시저장이 완료되었습니다.',
+ totalAmount
+ }
+ } catch (error) {
+ console.error('Failed to save pre-quote draft:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '임시저장에 실패했습니다.'
+ }
+ }
+}
+
+// 견적 문서 업로드
+export async function uploadPreQuoteDocument(
+ biddingId: number,
+ companyId: number,
+ file: File,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+ // 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: `bidding/${biddingId}/quotations`,
+ originalName: file.name,
+ userId
+ })
+
+ if (!saveResult.success) {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+
+ // 데이터베이스에 문서 정보 저장
+ const result = await db.insert(biddingDocuments)
+ .values({
+ biddingId,
+ companyId,
+ documentType: 'other', // 견적서 타입
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로)
+ title: `견적서 - ${file.name}`,
+ description: '협력업체 제출 견적서',
+ isPublic: false,
+ isRequired: false,
+ uploadedBy: userName,
+ uploadedAt: new Date()
+ })
+ .returning()
+
+ return {
+ success: true,
+ message: '견적서가 성공적으로 업로드되었습니다.',
+ documentId: result[0].id
+ }
+ } catch (error) {
+ console.error('Failed to upload pre-quote document:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.'
+ }
+ }
+}
+
+// 업로드된 견적 문서 목록 조회
+export async function getPreQuoteDocuments(biddingId: number, companyId: number) {
+ try {
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ )
+ )
+
+ return documents
+ } catch (error) {
+ console.error('Failed to get pre-quote documents:', error)
+ return []
+ }
+ }
+
+// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용)
+export async function getSavedPrItemQuotations(biddingCompanyId: number) {
+ try {
+ const savedQuotations = await db
+ .select({
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
+ technicalSpecification: companyPrItemBids.technicalSpecification,
+ currency: companyPrItemBids.currency
+ })
+ .from(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // Decimal 타입을 number로 변환
+ return savedQuotations.map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: parseFloat(item.bidUnitPrice || '0'),
+ bidAmount: parseFloat(item.bidAmount || '0'),
+ proposedDeliveryDate: item.proposedDeliveryDate,
+ technicalSpecification: item.technicalSpecification,
+ currency: item.currency
+ }))
+ } catch (error) {
+ console.error('Failed to get saved PR item quotations:', error)
+ return []
+ }
+ }
+
+// 견적 문서 정보 조회 (다운로드용)
+export async function getPreQuoteDocumentForDownload(
+ documentId: number,
+ biddingId: number,
+ companyId: number
+) {
+ try {
+ const document = await db
+ .select({
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ filePath: biddingDocuments.filePath
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ eq(biddingDocuments.documentType, 'other')
+ )
+ )
+ .limit(1)
+
+ if (document.length === 0) {
+ return {
+ success: false,
+ error: '문서를 찾을 수 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ document: document[0]
+ }
+ } catch (error) {
+ console.error('Failed to get pre-quote document:', error)
+ return {
+ success: false,
+ error: '문서 정보 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 견적 문서 삭제
+export async function deletePreQuoteDocument(
+ documentId: number,
+ biddingId: number,
+ companyId: number,
+ userId: string
+) {
+ try {
+ // 문서 존재 여부 및 권한 확인
+ const document = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ filePath: biddingDocuments.filePath,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ eq(biddingDocuments.documentType, 'other')
+ )
+ )
+ .limit(1)
+
+ if (document.length === 0) {
+ return {
+ success: false,
+ error: '문서를 찾을 수 없습니다.'
+ }
+ }
+
+ const doc = document[0]
+
+ // 데이터베이스에서 문서 정보 삭제
+ await db
+ .delete(biddingDocuments)
+ .where(eq(biddingDocuments.id, documentId))
+
+ return {
+ success: true,
+ message: '문서가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete pre-quote document:', error)
+ return {
+ success: false,
+ error: '문서 삭제에 실패했습니다.'
+ }
+ }
+ }
+
+// 기본계약 발송 (서버 액션)
+export async function sendBiddingBasicContracts(
+ biddingId: number,
+ vendorData: Array<{
+ vendorId: number
+ vendorName: string
+ vendorCode?: string
+ vendorCountry?: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails?: Array<{ email: string; name?: string }>
+ contractRequirements: {
+ ndaYn: boolean
+ generalGtcYn: boolean
+ projectGtcYn: boolean
+ agreementYn: boolean
+ }
+ biddingCompanyId: number
+ biddingId: number
+ hasExistingContracts?: boolean
+ }>,
+ generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }>,
+ message?: string
+) {
+ try {
+ console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) });
+
+ // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용)
+ const [currentUser] = await db.select().from(users).limit(1)
+
+ if (!currentUser) {
+ throw new Error("사용자 정보를 찾을 수 없습니다.")
+ }
+
+ const results = []
+ const savedContracts = []
+
+ // 트랜잭션 시작
+ const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
+ await mkdir(contractsDir, { recursive: true });
+
+ const result = await db.transaction(async (tx) => {
+ // 각 벤더별로 기본계약 생성 및 이메일 발송
+ for (const vendor of vendorData) {
+ // 기존 계약 확인 (biddingCompanyId 기준)
+ if (vendor.hasExistingContracts) {
+ console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`)
+ continue
+ }
+
+ // 벤더 정보 조회
+ const [vendorInfo] = await tx
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, vendor.vendorId))
+ .limit(1)
+
+ if (!vendorInfo) {
+ console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`)
+ continue
+ }
+
+ // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용)
+ console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`)
+ let [biddingCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, vendor.biddingCompanyId))
+ .limit(1)
+
+ console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo)
+ if (!biddingCompanyInfo) {
+ console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`)
+ // fallback: biddingId와 vendorId로 찾기 시도
+ console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`)
+ const [fallbackCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, vendor.vendorId)
+ ))
+ .limit(1)
+ console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo)
+ if (fallbackCompanyInfo) {
+ console.log(`Using fallback biddingCompanyInfo`)
+ biddingCompanyInfo = fallbackCompanyInfo
+ } else {
+ console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10))
+ continue
+ }
+ }
+
+ // 계약 요구사항에 따라 계약서 생성
+ const contractTypes: Array<{ type: string; templateName: string }> = []
+ if (vendor.contractRequirements?.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' })
+ if (vendor.contractRequirements?.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' })
+ if (vendor.contractRequirements?.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' })
+ if (vendor.contractRequirements?.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' })
+
+ // contractRequirements가 없거나 빈 객체인 경우 빈 배열로 처리
+ if (!vendor.contractRequirements || Object.keys(vendor.contractRequirements).length === 0) {
+ console.log(`Skipping vendor ${vendor.vendorId} - no contract requirements specified`)
+ continue
+ }
+
+ console.log("contractTypes", contractTypes)
+ for (const contractType of contractTypes) {
+ // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기)
+ console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key))
+ const pdfData = generatedPdfs.find((pdf: any) =>
+ pdf.key.includes(`${vendor.vendorId}_`) &&
+ pdf.key.includes(`_${contractType.templateName}`)
+ )
+ console.log("pdfData", pdfData, "for contractType", contractType)
+ if (!pdfData) {
+ console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`)
+ continue
+ }
+
+ // 파일 저장 (rfq-last 방식)
+ const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
+ const filePath = path.join(contractsDir, fileName);
+
+ await writeFile(filePath, Buffer.from(pdfData.buffer));
+
+ // 템플릿 정보 조회 (rfq-last 방식)
+ const [template] = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`),
+ eq(basicContractTemplates.status, "ACTIVE")
+ )
+ )
+ .limit(1);
+
+ console.log("템플릿", contractType.templateName, template);
+
+ // 기존 계약이 있는지 확인 (rfq-last 방식)
+ const [existingContract] = await tx
+ .select()
+ .from(basicContract)
+ .where(
+ and(
+ eq(basicContract.templateId, template?.id),
+ eq(basicContract.vendorId, vendor.vendorId),
+ eq(basicContract.biddingCompanyId, biddingCompanyInfo.id)
+ )
+ )
+ .limit(1);
+
+ let contractRecord;
+
+ if (existingContract) {
+ // 기존 계약이 있으면 업데이트
+ [contractRecord] = await tx
+ .update(basicContract)
+ .set({
+ requestedBy: currentUser.id,
+ status: "PENDING", // 재발송 상태
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
+ updatedAt: new Date(),
+ })
+ .where(eq(basicContract.id, existingContract.id))
+ .returning();
+
+ console.log("기존 계약 업데이트:", contractRecord.id);
+ } else {
+ // 새 계약 생성
+ [contractRecord] = await tx
+ .insert(basicContract)
+ .values({
+ templateId: template?.id || null,
+ vendorId: vendor.vendorId,
+ biddingCompanyId: biddingCompanyInfo.id,
+ rfqCompanyId: null,
+ generalContractId: null,
+ requestedBy: currentUser.id,
+ status: 'PENDING',
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ console.log("새 계약 생성:", contractRecord.id);
+ }
+
+ results.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ contractId: contractRecord.id,
+ contractType: contractType.type,
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ })
+
+ // savedContracts에 추가 (rfq-last 방식)
+ // savedContracts.push({
+ // vendorId: vendor.vendorId,
+ // vendorName: vendor.vendorName,
+ // templateName: contractType.templateName,
+ // contractId: contractRecord.id,
+ // fileName: fileName,
+ // isUpdated: !!existingContract, // 업데이트 여부 표시
+ // })
+ }
+
+ // 이메일 발송 (선택사항)
+ if (vendor.selectedMainEmail) {
+ try {
+ await sendEmail({
+ to: vendor.selectedMainEmail,
+ template: 'basic-contract-notification',
+ context: {
+ vendorName: vendor.vendorName,
+ biddingId: biddingId,
+ contractCount: contractTypes.length,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'),
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`,
+ message: message || '',
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ }
+ })
+ } catch (emailError) {
+ console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError)
+ // 이메일 발송 실패해도 계약 생성은 유지
+ }
+ }
+ }
+
+ return {
+ success: true,
+ message: `${results.length}개의 기본계약이 생성되었습니다.`,
+ results,
+ savedContracts,
+ totalContracts: savedContracts.length,
+ }
+ })
+
+ return result
+
+ } catch (error) {
+ console.error('기본계약 발송 실패:', error)
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : '기본계약 발송 중 오류가 발생했습니다.'
+ )
+ }
+}
+
+// 기존 기본계약 조회 (서버 액션)
+export async function getExistingBasicContractsForBidding(biddingId: number) {
+ try {
+ // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회
+ const existingContracts = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ biddingCompanyId: basicContract.biddingCompanyId,
+ biddingId: biddingCompanies.biddingId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ })
+ .from(basicContract)
+ .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id))
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ )
+ )
+
+ return {
+ success: true,
+ contracts: existingContracts
+ }
+
+ } catch (error) {
+ console.error('기존 계약 조회 실패:', error)
+ return {
+ success: false,
+ error: '기존 계약 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선정된 업체들 조회 (서버 액션)
+export async function getSelectedVendorsForBidding(biddingId: number) {
+ try {
+ const selectedCompanies = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+ companyEmail: vendors.email,
+ companyCountry: vendors.country,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ biddingId: biddingCompanies.biddingId,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isPreQuoteSelected, true)
+ ))
+
+ return {
+ success: true,
+ vendors: selectedCompanies.map(company => ({
+ vendorId: company.companyId, // 실제 vendor ID
+ vendorName: company.companyName || '',
+ vendorCode: company.companyCode,
+ vendorEmail: company.companyEmail,
+ vendorCountry: company.companyCountry || '대한민국',
+ contactPerson: company.contactPerson,
+ contactEmail: company.contactEmail,
+ biddingCompanyId: company.id, // biddingCompany ID
+ biddingId: company.biddingId,
+ ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
+ generalGtcYn: true,
+ projectGtcYn: true,
+ agreementYn: true
+ }))
+ }
+ } catch (error) {
+ console.error('선정된 업체 조회 실패:', error)
+ return {
+ success: false,
+ error: '선정된 업체 조회에 실패했습니다.',
+ vendors: []
+ }
+ }
} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx
deleted file mode 100644
index cfa629e3..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Badge } from '@/components/ui/badge'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
-import {
- FileText,
- Download,
- User,
- Calendar
-} from 'lucide-react'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { getPreQuoteDocuments, getPreQuoteDocumentForDownload } from '../service'
-import { downloadFile } from '@/lib/file-download'
-
-interface UploadedDocument {
- id: number
- fileName: string
- originalFileName: string
- fileSize: number | null
- filePath: string
- title: string | null
- description: string | null
- uploadedAt: string
- uploadedBy: string
-}
-
-interface BiddingPreQuoteAttachmentsDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- biddingId: number
- companyId: number
- companyName: string
-}
-
-export function BiddingPreQuoteAttachmentsDialog({
- open,
- onOpenChange,
- biddingId,
- companyId,
- companyName
-}: BiddingPreQuoteAttachmentsDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [documents, setDocuments] = React.useState<UploadedDocument[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
-
- // 다이얼로그가 열릴 때 첨부파일 목록 로드
- React.useEffect(() => {
- if (open) {
- loadDocuments()
- }
- }, [open, biddingId, companyId])
-
- const loadDocuments = async () => {
- setIsLoading(true)
- try {
- const docs = await getPreQuoteDocuments(biddingId, companyId)
- // Date를 string으로 변환
- const mappedDocs = docs.map(doc => ({
- ...doc,
- uploadedAt: doc.uploadedAt.toString(),
- uploadedBy: doc.uploadedBy || ''
- }))
- setDocuments(mappedDocs)
- } catch (error) {
- console.error('Failed to load documents:', error)
- toast({
- title: '오류',
- description: '첨부파일 목록을 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- } finally {
- setIsLoading(false)
- }
- }
-
- // 파일 다운로드
- const handleDownload = (document: UploadedDocument) => {
- startTransition(async () => {
- const result = await getPreQuoteDocumentForDownload(document.id, biddingId, companyId)
-
- if (result.success) {
- try {
- await downloadFile(result.document?.filePath, result.document?.originalFileName, {
- showToast: true
- })
- } catch (error) {
- toast({
- title: '다운로드 실패',
- description: '파일 다운로드에 실패했습니다.',
- variant: 'destructive',
- })
- }
- } else {
- toast({
- title: '다운로드 실패',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- // 파일 크기 포맷팅
- const formatFileSize = (bytes: number | null) => {
- if (!bytes) return '-'
- if (bytes === 0) return '0 Bytes'
- const k = 1024
- const sizes = ['Bytes', 'KB', 'MB', 'GB']
- const i = Math.floor(Math.log(bytes) / Math.log(k))
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- <span>협력업체 첨부파일</span>
- <span className="text-sm font-normal text-muted-foreground">
- - {companyName}
- </span>
- </DialogTitle>
- <DialogDescription>
- 협력업체가 제출한 견적 관련 첨부파일 목록입니다.
- </DialogDescription>
- </DialogHeader>
-
- {isLoading ? (
- <div className="flex items-center justify-center py-12">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
- <p className="text-muted-foreground">첨부파일 목록을 불러오는 중...</p>
- </div>
- </div>
- ) : documents.length > 0 ? (
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <Badge variant="secondary" className="text-sm">
- 총 {documents.length}개 파일
- </Badge>
- </div>
-
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>파일명</TableHead>
- <TableHead>크기</TableHead>
- <TableHead>업로드일</TableHead>
- <TableHead>작성자</TableHead>
- <TableHead className="w-24">작업</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {documents.map((doc) => (
- <TableRow key={doc.id}>
- <TableCell>
- <div className="flex items-center gap-2">
- <FileText className="w-4 h-4 text-gray-500" />
- <span className="truncate max-w-48" title={doc.originalFileName}>
- {doc.originalFileName}
- </span>
- </div>
- </TableCell>
- <TableCell className="text-sm text-gray-500">
- {formatFileSize(doc.fileSize)}
- </TableCell>
- <TableCell className="text-sm text-gray-500">
- <div className="flex items-center gap-1">
- <Calendar className="w-3 h-3" />
- {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
- </div>
- </TableCell>
- <TableCell className="text-sm text-gray-500">
- <div className="flex items-center gap-1">
- <User className="w-3 h-3" />
- {doc.uploadedBy}
- </div>
- </TableCell>
- <TableCell>
- <Button
- variant="outline"
- size="sm"
- onClick={() => handleDownload(doc)}
- disabled={isPending}
- title="다운로드"
- >
- <Download className="w-3 h-3" />
- </Button>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
- ) : (
- <div className="text-center py-12 text-gray-500">
- <FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
- <p className="text-lg font-medium mb-2">첨부파일이 없습니다</p>
- <p className="text-sm">협력업체가 아직 첨부파일을 업로드하지 않았습니다.</p>
- </div>
- )}
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
deleted file mode 100644
index 91b80bd3..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Bidding } from '@/db/schema'
-import { QuotationDetails } from '@/lib/bidding/detail/service'
-import { getBiddingCompanies } from '../service'
-
-import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table'
-
-interface BiddingPreQuoteContentProps {
- bidding: Bidding
- quotationDetails: QuotationDetails | null
- biddingCompanies: any[]
- prItems: any[]
-}
-
-export function BiddingPreQuoteContent({
- bidding,
- quotationDetails,
- biddingCompanies: initialBiddingCompanies,
- prItems
-}: BiddingPreQuoteContentProps) {
- const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies)
- const [refreshTrigger, setRefreshTrigger] = React.useState(0)
-
- const handleRefresh = React.useCallback(async () => {
- try {
- const result = await getBiddingCompanies(bidding.id)
- if (result.success && result.data) {
- setBiddingCompanies(result.data)
- }
- setRefreshTrigger(prev => prev + 1)
- } catch (error) {
- console.error('Failed to refresh bidding companies:', error)
- }
- }, [bidding.id])
-
- return (
- <div className="space-y-6">
- <BiddingPreQuoteVendorTableContent
- biddingId={bidding.id}
- bidding={bidding}
- biddingCompanies={biddingCompanies}
- onRefresh={handleRefresh}
- onOpenItemsDialog={() => {}}
- onOpenTargetPriceDialog={() => {}}
- onOpenSelectionReasonDialog={() => {}}
- />
- </div>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
deleted file mode 100644
index 3205df08..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
+++ /dev/null
@@ -1,770 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Badge } from '@/components/ui/badge'
-import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { sendPreQuoteInvitations, sendBiddingBasicContracts, getExistingBasicContractsForBidding } from '../service'
-import { getActiveContractTemplates } from '../../service'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { Mail, Building2, Calendar, FileText, CheckCircle, Info, RefreshCw } from 'lucide-react'
-import { Progress } from '@/components/ui/progress'
-import { Separator } from '@/components/ui/separator'
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { cn } from '@/lib/utils'
-
-interface BiddingPreQuoteInvitationDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- companies: BiddingCompany[]
- biddingId: number
- biddingTitle: string
- projectName?: string
- onSuccess: () => void
-}
-
-interface BasicContractTemplate {
- id: number
- templateName: string
- revision: number
- status: string
- filePath: string | null
- validityPeriod: number | null
- legalReviewRequired: boolean
- createdAt: Date | null
-}
-
-interface SelectedContract {
- templateId: number
- templateName: string
- contractType: string // templateName을 contractType으로 사용
- checked: boolean
-}
-
-// PDF 생성 유틸리티 함수
-const generateBasicContractPdf = async (
- template: BasicContractTemplate,
- vendorId: number
-): Promise<{ buffer: number[]; fileName: string }> => {
- try {
- // 1. 템플릿 데이터 준비 (서버 API 호출)
- const prepareResponse = await fetch("/api/contracts/prepare-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- templateName: template.templateName,
- vendorId,
- }),
- });
-
- if (!prepareResponse.ok) {
- throw new Error("템플릿 준비 실패");
- }
-
- const { template: preparedTemplate, templateData } = await prepareResponse.json();
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: preparedTemplate.filePath }),
- });
-
- const templateBlob = await templateResponse.blob();
- const templateFile = new window.File([templateBlob], "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- });
-
- // 3. PDFTron WebViewer로 PDF 변환
- const { default: WebViewer } = await import("@pdftron/webviewer");
-
- const tempDiv = document.createElement('div');
- tempDiv.style.display = 'none';
- document.body.appendChild(tempDiv);
-
- try {
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- );
-
- const { Core } = instance;
- const { createDocument } = Core;
-
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- });
-
- // 변수 치환 적용
- await templateDoc.applyTemplateValues(templateData);
-
- // PDF 변환
- const fileData = await templateDoc.getFileData();
- const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
-
- const fileName = `${template.templateName}_${Date.now()}.pdf`;
-
- return {
- buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
- fileName
- };
-
- } finally {
- if (tempDiv.parentNode) {
- document.body.removeChild(tempDiv);
- }
- }
- } catch (error) {
- console.error(`기본계약 PDF 생성 실패 (${template.templateName}):`, error);
- throw error;
- }
-};
-
-export function BiddingPreQuoteInvitationDialog({
- open,
- onOpenChange,
- companies,
- biddingId,
- biddingTitle,
- projectName,
- onSuccess
-}: BiddingPreQuoteInvitationDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([])
- const [preQuoteDeadline, setPreQuoteDeadline] = React.useState('')
- const [additionalMessage, setAdditionalMessage] = React.useState('')
-
- // 기본계약 관련 상태
- const [existingContracts, setExistingContracts] = React.useState<any[]>([])
- const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false)
- const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0)
- const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('')
-
- // 기본계약서 템플릿 관련 상태
- const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([])
- const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([])
- const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
-
- // 초대 가능한 업체들 (pending 상태인 업체들)
- const invitableCompanies = React.useMemo(() => companies.filter(company =>
- company.invitationStatus === 'pending' && company.companyName
- ), [companies])
-
- // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드
- React.useEffect(() => {
- if (open) {
- const fetchInitialData = async () => {
- setIsLoadingTemplates(true);
- try {
- const [contractsResult, templatesData] = await Promise.all([
- getExistingBasicContractsForBidding(biddingId),
- getActiveContractTemplates()
- ]);
-
- // 기존 계약 조회 - 서버 액션 사용
- const existingContractsResult = await getExistingBasicContractsForBidding(biddingId);
- setExistingContracts(existingContractsResult.success ? existingContractsResult.contracts || [] : []);
-
- // 템플릿 로드 (4개 타입만 필터링)
- // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료
- const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료'];
- const filteredTemplates = (templatesData.templates || []).filter((template: any) =>
- allowedTemplateNames.some(allowedName =>
- template.templateName.includes(allowedName) ||
- allowedName.includes(template.templateName)
- )
- );
- setAvailableTemplates(filteredTemplates as BasicContractTemplate[]);
- const initialSelected = filteredTemplates.map((template: any) => ({
- templateId: template.id,
- templateName: template.templateName,
- contractType: template.templateName,
- checked: false
- }));
- setSelectedContracts(initialSelected);
-
- } catch (error) {
- console.error('초기 데이터 로드 실패:', error);
- toast({
- title: '오류',
- description: '기본 정보를 불러오는 데 실패했습니다.',
- variant: 'destructive',
- });
- setExistingContracts([]);
- setAvailableTemplates([]);
- setSelectedContracts([]);
- } finally {
- setIsLoadingTemplates(false);
- }
- }
- fetchInitialData();
- }
- }, [open, biddingId, toast]);
-
- const handleSelectAll = (checked: boolean | 'indeterminate') => {
- if (checked) {
- // 기존 계약이 없는 업체만 선택
- const availableCompanies = invitableCompanies.filter(company =>
- !existingContracts.some(ec => ec.vendorId === company.companyId)
- )
- setSelectedCompanyIds(availableCompanies.map(company => company.id))
- } else {
- setSelectedCompanyIds([])
- }
- }
-
- const handleSelectCompany = (companyId: number, checked: boolean) => {
- const company = invitableCompanies.find(c => c.id === companyId)
- const hasExistingContract = company ? existingContracts.some(ec => ec.vendorId === company.companyId) : false
-
- if (hasExistingContract) {
- toast({
- title: '선택 불가',
- description: '이미 기본계약서를 받은 업체는 다시 선택할 수 없습니다.',
- variant: 'default',
- })
- return
- }
-
- if (checked) {
- setSelectedCompanyIds(prev => [...prev, companyId])
- } else {
- setSelectedCompanyIds(prev => prev.filter(id => id !== companyId))
- }
- }
-
- // 기본계약서 선택 토글
- const toggleContractSelection = (templateId: number) => {
- setSelectedContracts(prev =>
- prev.map(contract =>
- contract.templateId === templateId
- ? { ...contract, checked: !contract.checked }
- : contract
- )
- )
- }
-
- // 모든 기본계약서 선택/해제
- const toggleAllContractSelection = (checked: boolean | 'indeterminate') => {
- setSelectedContracts(prev =>
- prev.map(contract => ({ ...contract, checked: !!checked }))
- )
- }
-
- const handleSendInvitations = () => {
- if (selectedCompanyIds.length === 0) {
- toast({
- title: '알림',
- description: '초대를 발송할 업체를 선택해주세요.',
- variant: 'default',
- })
- return
- }
-
- const selectedContractTemplates = selectedContracts.filter(c => c.checked);
- const companiesForContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id));
-
- const vendorsToGenerateContracts = companiesForContracts.filter(company =>
- !existingContracts.some(ec =>
- ec.vendorId === company.companyId && ec.biddingCompanyId === company.id
- )
- );
-
- startTransition(async () => {
- try {
- // 1. 사전견적 초대 발송
- const invitationResponse = await sendPreQuoteInvitations(
- selectedCompanyIds,
- preQuoteDeadline || undefined
- )
-
- if (!invitationResponse.success) {
- toast({
- title: '초대 발송 실패',
- description: invitationResponse.error,
- variant: 'destructive',
- })
- return
- }
-
- // 2. 기본계약 발송 (선택된 템플릿과 업체가 있는 경우)
- let contractResponse: Awaited<ReturnType<typeof sendBiddingBasicContracts>> | null = null
- if (selectedContractTemplates.length > 0 && selectedCompanyIds.length > 0) {
- setIsGeneratingPdfs(true)
- setPdfGenerationProgress(0)
-
- const generatedPdfsMap = new Map<string, { buffer: number[], fileName: string }>()
-
- let generatedCount = 0;
- for (const vendor of vendorsToGenerateContracts) {
- for (const contract of selectedContractTemplates) {
- setCurrentGeneratingContract(`${vendor.companyName} - ${contract.templateName}`);
- const templateDetails = availableTemplates.find(t => t.id === contract.templateId);
-
- if (templateDetails) {
- const pdfData = await generateBasicContractPdf(templateDetails, vendor.companyId);
- // sendBiddingBasicContracts와 동일한 키 형식 사용
- let contractType = '';
- if (contract.templateName.includes('비밀')) {
- contractType = 'NDA';
- } else if (contract.templateName.includes('General GTC')) {
- contractType = 'General_GTC';
- } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) {
- contractType = 'Project_GTC';
- } else if (contract.templateName.includes('기술자료')) {
- contractType = '기술자료';
- }
- const key = `${vendor.companyId}_${contractType}_${contract.templateName}`;
- generatedPdfsMap.set(key, pdfData);
- }
- }
- generatedCount++;
- setPdfGenerationProgress((generatedCount / vendorsToGenerateContracts.length) * 100);
- }
-
- setIsGeneratingPdfs(false);
-
- const vendorData = companiesForContracts.map(company => {
- // 선택된 템플릿에 따라 contractRequirements 동적으로 설정
- const contractRequirements = {
- ndaYn: selectedContractTemplates.some(c => c.templateName.includes('비밀')),
- generalGtcYn: selectedContractTemplates.some(c => c.templateName.includes('General GTC')),
- projectGtcYn: selectedContractTemplates.some(c => c.templateName.includes('기술') && !c.templateName.includes('기술자료')),
- agreementYn: selectedContractTemplates.some(c => c.templateName.includes('기술자료'))
- };
-
- return {
- vendorId: company.companyId,
- vendorName: company.companyName || '',
- vendorCode: company.companyCode,
- vendorCountry: '대한민국',
- selectedMainEmail: company.contactEmail || '',
- contactPerson: company.contactPerson,
- contactEmail: company.contactEmail,
- biddingCompanyId: company.id,
- biddingId: biddingId,
- hasExistingContracts: existingContracts.some(ec =>
- ec.vendorId === company.companyId && ec.biddingCompanyId === company.id
- ),
- contractRequirements,
- additionalEmails: [],
- customEmails: []
- };
- });
-
- const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({
- key,
- buffer: data.buffer,
- fileName: data.fileName,
- }));
-
- console.log("Calling sendBiddingBasicContracts with biddingId:", biddingId);
- console.log("vendorData:", vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })));
-
- contractResponse = await sendBiddingBasicContracts(
- biddingId,
- vendorData,
- pdfsArray,
- additionalMessage
- );
- }
-
- let successMessage = '사전견적 초대가 성공적으로 발송되었습니다.';
- if (contractResponse && contractResponse.success) {
- successMessage += `\n${contractResponse.message}`;
- }
-
- toast({
- title: '성공',
- description: successMessage,
- })
-
- // 상태 초기화
- setSelectedCompanyIds([]);
- setPreQuoteDeadline('');
- setAdditionalMessage('');
- setExistingContracts([]);
- setIsGeneratingPdfs(false);
- setPdfGenerationProgress(0);
- setCurrentGeneratingContract('');
- setSelectedContracts(prev => prev.map(c => ({ ...c, checked: false })));
-
- onOpenChange(false);
- onSuccess();
-
- } catch (error) {
- console.error('발송 실패:', error);
- toast({
- title: '오류',
- description: '발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
- variant: 'destructive',
- });
- setIsGeneratingPdfs(false);
- }
- })
- }
-
- const handleOpenChange = (open: boolean) => {
- onOpenChange(open)
- if (!open) {
- setSelectedCompanyIds([])
- setPreQuoteDeadline('')
- setAdditionalMessage('')
- setExistingContracts([])
- setIsGeneratingPdfs(false)
- setPdfGenerationProgress(0)
- setCurrentGeneratingContract('')
- setSelectedContracts([])
- }
- }
-
- const selectedContractCount = selectedContracts.filter(c => c.checked).length;
- const selectedCompanyCount = selectedCompanyIds.length;
- const companiesToReceiveContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id));
-
- // 기존 계약이 없는 업체들만 계산
- const availableCompanies = invitableCompanies.filter(company =>
- !existingContracts.some(ec => ec.vendorId === company.companyId)
- );
- const selectedAvailableCompanyCount = selectedCompanyIds.filter(id =>
- availableCompanies.some(company => company.id === id)
- ).length;
-
- // 선택된 업체들 중 기존 계약이 있는 업체들
- const selectedCompaniesWithExistingContracts = invitableCompanies.filter(company =>
- selectedCompanyIds.includes(company.id) &&
- existingContracts.some(ec => ec.vendorId === company.companyId)
- );
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Mail className="w-5 h-5" />
- 사전견적 초대 및 기본계약 발송
- </DialogTitle>
- <DialogDescription>
- 선택한 업체들에게 사전견적 요청과 기본계약서를 발송합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(70vh - 200px)' }}>
- <div className="space-y-6 pr-4">
- {/* 견적 마감일 설정 */}
- <div className="mb-6 p-4 border rounded-lg bg-muted/30">
- <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2">
- <Calendar className="w-4 h-4" />
- 견적 마감일
- </Label>
- <Input
- id="preQuoteDeadline"
- type="datetime-local"
- value={preQuoteDeadline}
- onChange={(e) => setPreQuoteDeadline(e.target.value)}
- className="w-full"
- />
- </div>
-
- {/* 기존 계약 정보 알림 */}
- {existingContracts.length > 0 && (
- <Alert className="border-orange-500 bg-orange-50">
- <Info className="h-4 w-4 text-orange-600" />
- <AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle>
- <AlertDescription className="text-orange-700">
- 이미 기본계약을 받은 업체가 있습니다.
- 해당 업체들은 초대 대상에서 제외되며, 계약서 재생성도 건너뜁니다.
- </AlertDescription>
- </Alert>
- )}
-
- {/* 업체 선택 섹션 */}
- <Card className="border-2 border-dashed">
- <CardHeader className="pb-3">
- <CardTitle className="flex items-center gap-2 text-base">
- <Building2 className="h-5 w-5 text-green-600" />
- 초대 대상 업체
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {invitableCompanies.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- 초대 가능한 업체가 없습니다.
- </div>
- ) : (
- <>
- <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
- <div className="flex items-center gap-2">
- <Checkbox
- id="select-all-companies"
- checked={selectedAvailableCompanyCount === availableCompanies.length && availableCompanies.length > 0}
- onCheckedChange={handleSelectAll}
- />
- <Label htmlFor="select-all-companies" className="font-medium">
- 전체 선택 ({availableCompanies.length}개 업체)
- </Label>
- </div>
- <Badge variant="outline">
- {selectedCompanyCount}개 선택됨
- </Badge>
- </div>
-
- <div className="space-y-3 max-h-80 overflow-y-auto">
- {invitableCompanies.map((company) => {
- const hasExistingContract = existingContracts.some(ec => ec.vendorId === company.companyId);
- return (
- <div key={company.id} className={cn("flex items-center space-x-3 p-3 border rounded-lg transition-colors",
- selectedCompanyIds.includes(company.id) && !hasExistingContract && "border-green-500 bg-green-50",
- hasExistingContract && "border-orange-500 bg-orange-50 opacity-75"
- )}>
- <Checkbox
- id={`company-${company.id}`}
- checked={selectedCompanyIds.includes(company.id)}
- disabled={hasExistingContract}
- onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)}
- />
- <div className="flex-1">
- <div className="flex items-center gap-2">
- <span className={cn("font-medium", hasExistingContract && "text-muted-foreground")}>
- {company.companyName}
- </span>
- <Badge variant="outline" className="text-xs">
- {company.companyCode}
- </Badge>
- {hasExistingContract && (
- <Badge variant="secondary" className="text-xs">
- <CheckCircle className="h-3 w-3 mr-1" />
- 계약 체결됨
- </Badge>
- )}
- </div>
- {hasExistingContract && (
- <p className="text-xs text-orange-600 mt-1">
- 이미 기본계약서를 받은 업체입니다. 선택에서 제외됩니다.
- </p>
- )}
- </div>
- </div>
- )
- })}
- </div>
- </>
- )}
- </CardContent>
- </Card>
-
- {/* 선택된 업체 중 기존 계약이 있는 경우 경고 */}
- {selectedCompaniesWithExistingContracts.length > 0 && (
- <Alert className="border-red-500 bg-red-50">
- <Info className="h-4 w-4 text-red-600" />
- <AlertTitle className="text-red-800">선택한 업체 중 제외될 업체</AlertTitle>
- <AlertDescription className="text-red-700">
- 선택한 {selectedCompaniesWithExistingContracts.length}개 업체가 이미 기본계약서를 받았습니다.
- 이 업체들은 초대 발송 및 계약서 생성에서 제외됩니다.
- <br />
- <strong>실제 발송 대상: {selectedCompanyCount - selectedCompaniesWithExistingContracts.length}개 업체</strong>
- </AlertDescription>
- </Alert>
- )}
-
- {/* 기본계약서 선택 섹션 */}
- <Separator />
- <Card className="border-2 border-dashed">
- <CardHeader className="pb-3">
- <CardTitle className="flex items-center gap-2 text-base">
- <FileText className="h-5 w-5 text-blue-600" />
- 기본계약서 선택 (선택된 업체에만 발송)
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoadingTemplates ? (
- <div className="text-center py-6">
- <RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-blue-600" />
- <p className="text-sm text-muted-foreground">기본계약서 템플릿을 불러오는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {selectedCompanyCount === 0 && (
- <Alert className="border-red-500 bg-red-50">
- <Info className="h-4 w-4 text-red-600" />
- <AlertTitle className="text-red-800">알림</AlertTitle>
- <AlertDescription className="text-red-700">
- 기본계약서를 발송할 업체를 먼저 선택해주세요.
- </AlertDescription>
- </Alert>
- )}
- {availableTemplates.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>사용 가능한 기본계약서 템플릿이 없습니다.</p>
- </div>
- ) : (
- <>
- <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
- <div className="flex items-center gap-2">
- <Checkbox
- id="select-all-contracts"
- checked={selectedContracts.length > 0 && selectedContracts.every(c => c.checked)}
- onCheckedChange={toggleAllContractSelection}
- />
- <Label htmlFor="select-all-contracts" className="font-medium">
- 전체 선택 ({availableTemplates.length}개 템플릿)
- </Label>
- </div>
- <Badge variant="outline">
- {selectedContractCount}개 선택됨
- </Badge>
- </div>
- <div className="grid gap-3 max-h-60 overflow-y-auto">
- {selectedContracts.map((contract) => (
- <div
- key={contract.templateId}
- className={cn(
- "flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer",
- contract.checked && "border-blue-500 bg-blue-50"
- )}
- onClick={() => toggleContractSelection(contract.templateId)}
- >
- <div className="flex items-center gap-3">
- <Checkbox
- id={`contract-${contract.templateId}`}
- checked={contract.checked}
- onCheckedChange={() => toggleContractSelection(contract.templateId)}
- />
- <div className="flex-1">
- <Label
- htmlFor={`contract-${contract.templateId}`}
- className="font-medium cursor-pointer"
- >
- {contract.templateName}
- </Label>
- <p className="text-xs text-muted-foreground mt-1">
- {contract.contractType}
- </p>
- </div>
- </div>
- </div>
- ))}
- </div>
- </>
- )}
- {selectedContractCount > 0 && (
- <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
- <div className="flex items-center gap-2 mb-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium text-green-900 text-sm">
- 선택된 기본계약서 ({selectedContractCount}개)
- </span>
- </div>
- <ul className="space-y-1 text-xs text-green-800 list-disc list-inside">
- {selectedContracts.filter(c => c.checked).map((contract) => (
- <li key={contract.templateId}>
- {contract.templateName}
- </li>
- ))}
- </ul>
- </div>
- )}
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 추가 메시지 */}
- <div className="space-y-2">
- <Label htmlFor="contractMessage" className="text-sm font-medium">
- 계약서 추가 메시지 (선택사항)
- </Label>
- <textarea
- id="contractMessage"
- className="w-full min-h-[60px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary"
- placeholder="기본계약서와 함께 보낼 추가 메시지를 입력하세요..."
- value={additionalMessage}
- onChange={(e) => setAdditionalMessage(e.target.value)}
- />
- </div>
-
- {/* PDF 생성 진행 상황 */}
- {isGeneratingPdfs && (
- <Alert className="border-blue-500 bg-blue-50">
- <div className="space-y-3">
- <div className="flex items-center gap-2">
- <RefreshCw className="h-4 w-4 animate-spin text-blue-600" />
- <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle>
- </div>
- <AlertDescription>
- <div className="space-y-2">
- <p className="text-sm text-blue-700">{currentGeneratingContract}</p>
- <Progress value={pdfGenerationProgress} className="h-2" />
- <p className="text-xs text-blue-600">
- {Math.round(pdfGenerationProgress)}% 완료
- </p>
- </div>
- </AlertDescription>
- </div>
- </Alert>
- )}
- </div>
- </div>
-
- <DialogFooter className="flex-col sm:flex-row-reverse sm:justify-between items-center px-4 pt-4">
- <div className="flex gap-2 w-full sm:w-auto">
- <Button variant="outline" onClick={() => handleOpenChange(false)} className="w-full sm:w-auto">
- 취소
- </Button>
- <Button
- onClick={handleSendInvitations}
- disabled={isPending || selectedCompanyCount === 0 || isGeneratingPdfs}
- className="w-full sm:w-auto"
- >
- {isPending ? (
- <>
- <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
- 발송 중...
- </>
- ) : (
- <>
- <Mail className="w-4 h-4 mr-2" />
- 초대 발송 및 계약서 생성
- </>
- )}
- </Button>
- </div>
- {/* {(selectedCompanyCount > 0 || selectedContractCount > 0) && (
- <div className="mt-4 sm:mt-0 text-sm text-muted-foreground">
- {selectedCompanyCount > 0 && (
- <p>
- <strong>{selectedCompanyCount}개 업체</strong>에 초대를 발송합니다.
- </p>
- )}
- {selectedContractCount > 0 && selectedCompanyCount > 0 && (
- <p>
- 이 중 <strong>{companiesToReceiveContracts.length}개 업체</strong>에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다.
- </p>
- )}
- </div>
- )} */}
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
deleted file mode 100644
index f676709c..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { PrItemsPricingTable } from '../../vendor/components/pr-items-pricing-table'
-import { getSavedPrItemQuotations } from '../service'
-
-interface PrItem {
- id: number
- itemNumber: string | null
- prNumber: string | null
- itemInfo: string | null
- materialDescription: string | null
- quantity: string | null
- quantityUnit: string | null
- totalWeight: string | null
- weightUnit: string | null
- currency: string | null
- requestedDeliveryDate: string | null
- hasSpecDocument: boolean | null
-}
-
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-interface BiddingPreQuoteItemDetailsDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- biddingId: number
- biddingCompanyId: number
- companyName: string
- prItems: PrItem[]
- currency?: string
-}
-
-export function BiddingPreQuoteItemDetailsDialog({
- open,
- onOpenChange,
- biddingId,
- biddingCompanyId,
- companyName,
- prItems,
- currency = 'KRW'
-}: BiddingPreQuoteItemDetailsDialogProps) {
- const [prItemQuotations, setPrItemQuotations] = React.useState<PrItemQuotation[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
-
- // 다이얼로그가 열릴 때 저장된 품목별 견적 데이터 로드
- React.useEffect(() => {
- if (open && biddingCompanyId) {
- loadSavedQuotations()
- }
- }, [open, biddingCompanyId])
-
- const loadSavedQuotations = async () => {
- setIsLoading(true)
- try {
- console.log('Loading saved quotations for biddingCompanyId:', biddingCompanyId)
- const savedQuotations = await getSavedPrItemQuotations(biddingCompanyId)
- console.log('Loaded saved quotations:', savedQuotations)
- setPrItemQuotations(savedQuotations)
- } catch (error) {
- console.error('Failed to load saved quotations:', error)
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleQuotationsChange = (quotations: PrItemQuotation[]) => {
- // ReadOnly 모드이므로 변경사항을 저장하지 않음
- console.log('Quotations changed (readonly):', quotations)
- }
-
- const handleTotalAmountChange = (total: number) => {
- // ReadOnly 모드이므로 총 금액 변경을 처리하지 않음
- console.log('Total amount changed (readonly):', total)
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <span>품목별 견적 상세</span>
- <span className="text-sm font-normal text-muted-foreground">
- - {companyName}
- </span>
- </DialogTitle>
- <DialogDescription>
- 협력업체가 제출한 품목별 견적 상세 정보입니다.
- </DialogDescription>
- </DialogHeader>
-
- {isLoading ? (
- <div className="flex items-center justify-center py-12">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
- <p className="text-muted-foreground">견적 정보를 불러오는 중...</p>
- </div>
- </div>
- ) : (
- <PrItemsPricingTable
- prItems={prItems}
- initialQuotations={prItemQuotations}
- currency={currency}
- onQuotationsChange={handleQuotationsChange}
- onTotalAmountChange={handleTotalAmountChange}
- readOnly={true}
- />
- )}
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx
deleted file mode 100644
index e0194f2a..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { updatePreQuoteSelection } from '../service'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Badge } from '@/components/ui/badge'
-import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-
-interface BiddingPreQuoteSelectionDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedCompanies: BiddingCompany[]
- onSuccess: () => void
-}
-
-export function BiddingPreQuoteSelectionDialog({
- open,
- onOpenChange,
- selectedCompanies,
- onSuccess
-}: BiddingPreQuoteSelectionDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- // 선택된 업체들의 현재 상태 분석 (선정만 가능)
- const unselectedCompanies = selectedCompanies.filter(c => !c.isPreQuoteSelected)
- const hasQuotationCompanies = selectedCompanies.filter(c => c.preQuoteAmount && Number(c.preQuoteAmount) > 0)
-
- const handleConfirm = () => {
- const companyIds = selectedCompanies.map(c => c.id)
- const isSelected = true // 항상 선정으로 고정
-
- startTransition(async () => {
- const result = await updatePreQuoteSelection(
- companyIds,
- isSelected
- )
-
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
- onSuccess()
- onOpenChange(false)
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const getActionIcon = (isSelected: boolean) => {
- return isSelected ?
- <CheckCircle className="h-4 w-4 text-muted-foreground" /> :
- <CheckCircle className="h-4 w-4 text-green-600" />
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <AlertCircle className="h-5 w-5 text-amber-500" />
- 본입찰 선정 상태 변경
- </DialogTitle>
- <DialogDescription>
- 선택된 {selectedCompanies.length}개 업체의 본입찰 선정 상태를 변경합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4">
- {/* 견적 제출 여부 안내 */}
- {hasQuotationCompanies.length !== selectedCompanies.length && (
- <div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
- <div className="flex items-center gap-2 text-amber-800">
- <AlertCircle className="h-4 w-4" />
- <span className="text-sm font-medium">알림</span>
- </div>
- <p className="text-sm text-amber-700 mt-1">
- 사전견적을 제출하지 않은 업체도 포함되어 있습니다.
- 견적 미제출 업체도 본입찰에 참여시키시겠습니까?
- </p>
- </div>
- )}
-
- {/* 업체 목록 */}
- <div className="border rounded-lg">
- <div className="p-3 bg-muted/50 border-b">
- <h4 className="font-medium">대상 업체 목록</h4>
- </div>
- <div className="max-h-64 overflow-y-auto">
- {selectedCompanies.map((company) => (
- <div key={company.id} className="flex items-center justify-between p-3 border-b last:border-b-0">
- <div className="flex items-center gap-3">
- {getActionIcon(company.isPreQuoteSelected)}
- <div>
- <div className="font-medium">{company.companyName}</div>
- <div className="text-sm text-muted-foreground">{company.companyCode}</div>
- </div>
- </div>
- <div className="flex items-center gap-2">
- <Badge variant={company.isPreQuoteSelected ? 'default' : 'secondary'}>
- {company.isPreQuoteSelected ? '현재 선정' : '현재 미선정'}
- </Badge>
- {company.preQuoteAmount && Number(company.preQuoteAmount) > 0 ? (
- <Badge variant="outline" className="text-green-600">
- 견적 제출
- </Badge>
- ) : (
- <Badge variant="outline" className="text-muted-foreground">
- 견적 미제출
- </Badge>
- )}
- </div>
- </div>
- ))}
- </div>
- </div>
-
- {/* 결과 요약 */}
- <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
- <h5 className="font-medium text-blue-900 mb-2">변경 결과</h5>
- <div className="text-sm text-blue-800">
- <p>• {unselectedCompanies.length}개 업체가 본입찰 대상으로 <span className="font-medium text-green-600">선정</span>됩니다.</p>
- {selectedCompanies.length > unselectedCompanies.length && (
- <p>• {selectedCompanies.length - unselectedCompanies.length}개 업체는 이미 선정 상태이므로 변경되지 않습니다.</p>
- )}
- </div>
- </div>
- </div>
-
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 취소
- </Button>
- <Button onClick={handleConfirm} disabled={isPending}>
- {isPending ? '처리 중...' : '확인'}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
deleted file mode 100644
index 3266a568..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
+++ /dev/null
@@ -1,398 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type ColumnDef } from "@tanstack/react-table"
-import { Checkbox } from "@/components/ui/checkbox"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-import {
- MoreHorizontal, Edit, Trash2, Paperclip
-} from "lucide-react"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-
-// bidding_companies 테이블 타입 정의 (company_condition_responses와 join)
-export interface BiddingCompany {
- id: number
- biddingId: number
- companyId: number
- invitationStatus: 'pending' | 'sent' | 'accepted' | 'declined' | 'submitted'
- invitedAt: Date | null
- respondedAt: Date | null
- preQuoteAmount: string | null
- preQuoteSubmittedAt: Date | null
- preQuoteDeadline: Date | null
- isPreQuoteSelected: boolean
- isPreQuoteParticipated: boolean | null
- isAttendingMeeting: boolean | null
- notes: string | null
- contactPerson: string | null
- contactEmail: string | null
- contactPhone: string | null
- createdAt: Date
- updatedAt: Date
-
- // company_condition_responses 필드들
- paymentTermsResponse: string | null
- taxConditionsResponse: string | null
- proposedContractDeliveryDate: string | null
- priceAdjustmentResponse: boolean | null
- isInitialResponse: boolean | null
- incotermsResponse: string | null
- proposedShippingPort: string | null
- proposedDestinationPort: string | null
- sparePartResponse: string | null
- additionalProposals: string | null
-
- // 조인된 업체 정보
- companyName?: string
- companyCode?: string
-}
-
-interface GetBiddingCompanyColumnsProps {
- onEdit: (company: BiddingCompany) => void
- onDelete: (company: BiddingCompany) => void
- onViewPriceAdjustment?: (company: BiddingCompany) => void
- onViewItemDetails?: (company: BiddingCompany) => void
- onViewAttachments?: (company: BiddingCompany) => void
-}
-
-export function getBiddingPreQuoteVendorColumns({
- onEdit,
- onDelete,
- onViewPriceAdjustment,
- onViewItemDetails,
- onViewAttachments
-}: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] {
- return [
- {
- id: 'select',
- header: ({ table }) => (
- <Checkbox
- checked={table.getIsAllPageRowsSelected()}
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="모두 선택"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="행 선택"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- },
- {
- accessorKey: 'companyName',
- header: '업체명',
- cell: ({ row }) => (
- <div className="font-medium">{row.original.companyName || '-'}</div>
- ),
- },
- {
- accessorKey: 'companyCode',
- header: '업체코드',
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.original.companyCode || '-'}</div>
- ),
- },
- {
- accessorKey: 'invitationStatus',
- header: '초대 상태',
- cell: ({ row }) => {
- const status = row.original.invitationStatus
- let variant: any
- let label: string
-
- if (status === 'accepted') {
- variant = 'default'
- label = '수락'
- } else if (status === 'declined') {
- variant = 'destructive'
- label = '거절'
- } else if (status === 'pending') {
- variant = 'outline'
- label = '대기중'
- } else if (status === 'sent') {
- variant = 'outline'
- label = '요청됨'
- } else if (status === 'submitted') {
- variant = 'outline'
- label = '제출됨'
- } else {
- variant = 'outline'
- label = status || '-'
- }
-
- return <Badge variant={variant}>{label}</Badge>
- },
- },
- {
- accessorKey: 'preQuoteAmount',
- header: '사전견적금액',
- cell: ({ row }) => {
- const hasAmount = row.original.preQuoteAmount && Number(row.original.preQuoteAmount) > 0
- return (
- <div className="text-right font-mono">
- {hasAmount ? (
- <button
- onClick={() => onViewItemDetails?.(row.original)}
- className="text-primary hover:text-primary/80 hover:underline cursor-pointer"
- title="품목별 견적 상세 보기"
- >
- {Number(row.original.preQuoteAmount).toLocaleString()} KRW
- </button>
- ) : (
- <span className="text-muted-foreground">-</span>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'preQuoteSubmittedAt',
- header: '사전견적 제출일',
- cell: ({ row }) => (
- <div className="text-sm">
- {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'}
- </div>
- ),
- },
- {
- accessorKey: 'preQuoteDeadline',
- header: '사전견적 마감일',
- cell: ({ row }) => {
- const deadline = row.original.preQuoteDeadline
- if (!deadline) {
- return <div className="text-muted-foreground text-sm">-</div>
- }
-
- const now = new Date()
- const deadlineDate = new Date(deadline)
- const isExpired = deadlineDate < now
-
- return (
- <div className={`text-sm ${isExpired ? 'text-red-600' : ''}`}>
- <div>{deadlineDate.toLocaleDateString('ko-KR')}</div>
- {isExpired && (
- <Badge variant="destructive" className="text-xs mt-1">
- 마감
- </Badge>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'attachments',
- header: '첨부파일',
- cell: ({ row }) => {
- const hasAttachments = row.original.preQuoteSubmittedAt // 제출된 경우에만 첨부파일이 있을 수 있음
- return (
- <div className="text-center">
- {hasAttachments ? (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => onViewAttachments?.(row.original)}
- className="h-8 w-8 p-0"
- title="첨부파일 보기"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- ) : (
- <span className="text-muted-foreground text-sm">-</span>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'isPreQuoteParticipated',
- header: '사전견적 참여의사',
- cell: ({ row }) => {
- const participated = row.original.isPreQuoteParticipated
- if (participated === null) {
- return <Badge variant="outline">미결정</Badge>
- }
- return (
- <Badge variant={participated ? 'default' : 'destructive'}>
- {participated ? '참여' : '미참여'}
- </Badge>
- )
- },
- },
- {
- accessorKey: 'isPreQuoteSelected',
- header: '본입찰 선정',
- cell: ({ row }) => (
- <Badge variant={row.original.isPreQuoteSelected ? 'default' : 'secondary'}>
- {row.original.isPreQuoteSelected ? '선정' : '미선정'}
- </Badge>
- ),
- },
- {
- accessorKey: 'isAttendingMeeting',
- header: '사양설명회 참석',
- cell: ({ row }) => {
- const isAttending = row.original.isAttendingMeeting
- if (isAttending === null) return <div className="text-sm">-</div>
- return (
- <Badge variant={isAttending ? 'default' : 'secondary'}>
- {isAttending ? '참석' : '불참석'}
- </Badge>
- )
- },
- },
- {
- accessorKey: 'paymentTermsResponse',
- header: '지급조건',
- cell: ({ row }) => (
- <div className="text-sm max-w-32 truncate" title={row.original.paymentTermsResponse || ''}>
- {row.original.paymentTermsResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'taxConditionsResponse',
- header: '세금조건',
- cell: ({ row }) => (
- <div className="text-sm max-w-32 truncate" title={row.original.taxConditionsResponse || ''}>
- {row.original.taxConditionsResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'incotermsResponse',
- header: '운송조건',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.incotermsResponse || ''}>
- {row.original.incotermsResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'isInitialResponse',
- header: '초도여부',
- cell: ({ row }) => {
- const isInitial = row.original.isInitialResponse
- if (isInitial === null) return <div className="text-sm">-</div>
- return (
- <Badge variant={isInitial ? 'default' : 'secondary'}>
- {isInitial ? 'Y' : 'N'}
- </Badge>
- )
- },
- },
- {
- accessorKey: 'priceAdjustmentResponse',
- header: '연동제',
- cell: ({ row }) => {
- const hasPriceAdjustment = row.original.priceAdjustmentResponse
- if (hasPriceAdjustment === null) return <div className="text-sm">-</div>
- return (
- <div className="flex items-center gap-2">
- <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}>
- {hasPriceAdjustment ? '적용' : '미적용'}
- </Badge>
- {hasPriceAdjustment && onViewPriceAdjustment && (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => onViewPriceAdjustment(row.original)}
- className="h-6 px-2 text-xs"
- >
- 상세
- </Button>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'proposedContractDeliveryDate',
- header: '제안납기일',
- cell: ({ row }) => (
- <div className="text-sm">
- {row.original.proposedContractDeliveryDate ?
- new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
- </div>
- ),
- },
- {
- accessorKey: 'proposedShippingPort',
- header: '제안선적지',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.proposedShippingPort || ''}>
- {row.original.proposedShippingPort || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'proposedDestinationPort',
- header: '제안하역지',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}>
- {row.original.proposedDestinationPort || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'sparePartResponse',
- header: '스페어파트',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.sparePartResponse || ''}>
- {row.original.sparePartResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'additionalProposals',
- header: '추가제안',
- cell: ({ row }) => (
- <div className="text-sm max-w-32 truncate" title={row.original.additionalProposals || ''}>
- {row.original.additionalProposals || '-'}
- </div>
- ),
- },
- {
- id: 'actions',
- header: '액션',
- cell: ({ row }) => {
- const company = row.original
-
- return (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">메뉴 열기</span>
- <MoreHorizontal className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- {/* <DropdownMenuItem onClick={() => onEdit(company)}>
- <Edit className="mr-2 h-4 w-4" />
- 수정
- </DropdownMenuItem> */}
- <DropdownMenuItem
- onClick={() => onDelete(company)}
- className="text-destructive"
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- },
- ]
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
deleted file mode 100644
index bd078192..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
+++ /dev/null
@@ -1,311 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import {
- Popover,
- PopoverContent,
- PopoverTrigger
-} from '@/components/ui/popover'
-import { Check, ChevronsUpDown, Loader2, X, Plus, Search } from 'lucide-react'
-import { cn } from '@/lib/utils'
-import { createBiddingCompany } from '@/lib/bidding/pre-quote/service'
-import { searchVendorsForBidding } from '@/lib/bidding/service'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { Badge } from '@/components/ui/badge'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { ScrollArea } from '@/components/ui/scroll-area'
-import { Alert, AlertDescription } from '@/components/ui/alert'
-import { Info } from 'lucide-react'
-
-interface BiddingPreQuoteVendorCreateDialogProps {
- biddingId: number
- open: boolean
- onOpenChange: (open: boolean) => void
- onSuccess: () => void
-}
-
-interface Vendor {
- id: number
- vendorName: string
- vendorCode: string
- status: string
-}
-
-export function BiddingPreQuoteVendorCreateDialog({
- biddingId,
- open,
- onOpenChange,
- onSuccess
-}: BiddingPreQuoteVendorCreateDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
-
- // Vendor 검색 상태
- const [vendorList, setVendorList] = React.useState<Vendor[]>([])
- const [selectedVendors, setSelectedVendors] = React.useState<Vendor[]>([])
- const [vendorOpen, setVendorOpen] = React.useState(false)
-
-
- // 벤더 로드
- const loadVendors = React.useCallback(async () => {
- try {
- const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드
- setVendorList(result || [])
- } catch (error) {
- console.error('Failed to load vendors:', error)
- toast({
- title: '오류',
- description: '벤더 목록을 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- setVendorList([])
- }
- }, [biddingId])
-
- React.useEffect(() => {
- if (open) {
- loadVendors()
- }
- }, [open, loadVendors])
-
- // 초기화
- React.useEffect(() => {
- if (!open) {
- setSelectedVendors([])
- }
- }, [open])
-
- // 벤더 추가
- const handleAddVendor = (vendor: Vendor) => {
- if (!selectedVendors.find(v => v.id === vendor.id)) {
- setSelectedVendors([...selectedVendors, vendor])
- }
- setVendorOpen(false)
- }
-
- // 벤더 제거
- const handleRemoveVendor = (vendorId: number) => {
- setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId))
- }
-
- // 이미 선택된 벤더인지 확인
- const isVendorSelected = (vendorId: number) => {
- return selectedVendors.some(v => v.id === vendorId)
- }
-
- const handleCreate = () => {
- if (selectedVendors.length === 0) {
- toast({
- title: '오류',
- description: '업체를 선택해주세요.',
- variant: 'destructive',
- })
- return
- }
-
- startTransition(async () => {
- let successCount = 0
- let errorMessages: string[] = []
-
- for (const vendor of selectedVendors) {
- try {
- const response = await createBiddingCompany({
- biddingId,
- companyId: vendor.id,
- })
-
- if (response.success) {
- successCount++
- } else {
- errorMessages.push(`${vendor.vendorName}: ${response.error}`)
- }
- } catch (error) {
- errorMessages.push(`${vendor.vendorName}: 처리 중 오류가 발생했습니다.`)
- }
- }
-
- if (successCount > 0) {
- toast({
- title: '성공',
- description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`,
- })
- onOpenChange(false)
- resetForm()
- onSuccess()
- }
-
- if (errorMessages.length > 0 && successCount === 0) {
- toast({
- title: '오류',
- description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`,
- variant: 'destructive',
- })
- }
- })
- }
-
- const resetForm = () => {
- setSelectedVendors([])
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col">
- {/* 헤더 */}
- <DialogHeader className="p-6 pb-0">
- <DialogTitle>사전견적 업체 추가</DialogTitle>
- <DialogDescription>
- 견적 요청을 보낼 업체를 선택하세요. 여러 개 선택 가능합니다.
- </DialogDescription>
- </DialogHeader>
-
- {/* 메인 컨텐츠 */}
- <div className="flex-1 px-6 py-4 overflow-y-auto">
- <div className="space-y-6">
- {/* 업체 선택 카드 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">업체 선택</CardTitle>
- <CardDescription>
- 사전견적을 발송할 업체를 선택하세요.
- </CardDescription>
- </CardHeader>
- <CardContent>
- <div className="space-y-4">
- {/* 업체 추가 버튼 */}
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className="w-full justify-between"
- disabled={vendorList.length === 0}
- >
- <span className="flex items-center gap-2">
- <Plus className="h-4 w-4" />
- 업체 선택하기
- </span>
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[500px] p-0" align="start">
- <Command>
- <CommandInput placeholder="업체명 또는 코드로 검색..." />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendorList
- .filter(vendor => !isVendorSelected(vendor.id))
- .map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorCode} ${vendor.vendorName}`}
- onSelect={() => handleAddVendor(vendor)}
- >
- <div className="flex items-center gap-2 w-full">
- <Badge variant="outline" className="shrink-0">
- {vendor.vendorCode}
- </Badge>
- <span className="truncate">{vendor.vendorName}</span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
-
- {/* 선택된 업체 목록 */}
- {selectedVendors.length > 0 && (
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4>
- </div>
- <div className="space-y-2">
- {selectedVendors.map((vendor, index) => (
- <div
- key={vendor.id}
- className="flex items-center justify-between p-3 rounded-lg bg-secondary/50"
- >
- <div className="flex items-center gap-3">
- <span className="text-sm text-muted-foreground">
- {index + 1}.
- </span>
- <Badge variant="outline">
- {vendor.vendorCode}
- </Badge>
- <span className="text-sm font-medium">
- {vendor.vendorName}
- </span>
- </div>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => handleRemoveVendor(vendor.id)}
- className="h-8 w-8 p-0"
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {selectedVendors.length === 0 && (
- <div className="text-center py-8 text-muted-foreground">
- <p className="text-sm">아직 선택된 업체가 없습니다.</p>
- <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- </div>
-
- {/* 푸터 */}
- <DialogFooter className="p-6 pt-0 border-t">
- <Button
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isPending}
- >
- 취소
- </Button>
- <Button
- onClick={handleCreate}
- disabled={isPending || selectedVendors.length === 0}
- >
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {selectedVendors.length > 0
- ? `${selectedVendors.length}개 업체 추가`
- : '업체 추가'
- }
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
deleted file mode 100644
index 03bf2ecb..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Textarea } from '@/components/ui/textarea'
-import { Checkbox } from '@/components/ui/checkbox'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { updateBiddingCompany } from '../service'
-import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-
-interface BiddingPreQuoteVendorEditDialogProps {
- company: BiddingCompany | null
- open: boolean
- onOpenChange: (open: boolean) => void
- onSuccess: () => void
-}
-
-export function BiddingPreQuoteVendorEditDialog({
- company,
- open,
- onOpenChange,
- onSuccess
-}: BiddingPreQuoteVendorEditDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
-
- // 폼 상태
- const [formData, setFormData] = React.useState({
- contactPerson: '',
- contactEmail: '',
- contactPhone: '',
- preQuoteAmount: 0,
- notes: '',
- invitationStatus: 'pending' as 'pending' | 'accepted' | 'declined',
- isPreQuoteSelected: false,
- isAttendingMeeting: false,
- })
-
- // company가 변경되면 폼 데이터 업데이트
- React.useEffect(() => {
- if (company) {
- setFormData({
- contactPerson: company.contactPerson || '',
- contactEmail: company.contactEmail || '',
- contactPhone: company.contactPhone || '',
- preQuoteAmount: company.preQuoteAmount ? Number(company.preQuoteAmount) : 0,
- notes: company.notes || '',
- invitationStatus: company.invitationStatus,
- isPreQuoteSelected: company.isPreQuoteSelected,
- isAttendingMeeting: company.isAttendingMeeting || false,
- })
- }
- }, [company])
-
- const handleEdit = () => {
- if (!company) return
-
- startTransition(async () => {
- const response = await updateBiddingCompany(company.id, formData)
-
- if (response.success) {
- toast({
- title: '성공',
- description: response.message,
- })
- onOpenChange(false)
- onSuccess()
- } else {
- toast({
- title: '오류',
- description: response.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle>사전견적 업체 수정</DialogTitle>
- <DialogDescription>
- {company?.companyName} 업체의 사전견적 정보를 수정해주세요.
- </DialogDescription>
- </DialogHeader>
- <div className="grid gap-4 py-4">
- <div className="grid grid-cols-3 gap-4">
- <div className="space-y-2">
- <Label htmlFor="edit-contactPerson">담당자</Label>
- <Input
- id="edit-contactPerson"
- value={formData.contactPerson}
- onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-contactEmail">이메일</Label>
- <Input
- id="edit-contactEmail"
- type="email"
- value={formData.contactEmail}
- onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-contactPhone">연락처</Label>
- <Input
- id="edit-contactPhone"
- value={formData.contactPhone}
- onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
- />
- </div>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div className="space-y-2">
- <Label htmlFor="edit-preQuoteAmount">사전견적금액</Label>
- <Input
- id="edit-preQuoteAmount"
- type="number"
- value={formData.preQuoteAmount}
- onChange={(e) => setFormData({ ...formData, preQuoteAmount: Number(e.target.value) })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-invitationStatus">초대 상태</Label>
- <Select value={formData.invitationStatus} onValueChange={(value: any) => setFormData({ ...formData, invitationStatus: value })}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="pending">대기중</SelectItem>
- <SelectItem value="accepted">수락</SelectItem>
- <SelectItem value="declined">거절</SelectItem>
- </SelectContent>
- </Select>
- </div>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div className="flex items-center space-x-2">
- <Checkbox
- id="edit-isPreQuoteSelected"
- checked={formData.isPreQuoteSelected}
- onCheckedChange={(checked) =>
- setFormData({ ...formData, isPreQuoteSelected: !!checked })
- }
- />
- <Label htmlFor="edit-isPreQuoteSelected">본입찰 선정</Label>
- </div>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="edit-isAttendingMeeting"
- checked={formData.isAttendingMeeting}
- onCheckedChange={(checked) =>
- setFormData({ ...formData, isAttendingMeeting: !!checked })
- }
- />
- <Label htmlFor="edit-isAttendingMeeting">사양설명회 참석</Label>
- </div>
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-notes">특이사항</Label>
- <Textarea
- id="edit-notes"
- value={formData.notes}
- onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
- placeholder="특이사항을 입력해주세요..."
- />
- </div>
- </div>
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 취소
- </Button>
- <Button onClick={handleEdit} disabled={isPending}>
- 수정
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
deleted file mode 100644
index 5f600882..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { type DataTableAdvancedFilterField, type DataTableFilterField } 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 { BiddingPreQuoteVendorToolbarActions } from './bidding-pre-quote-vendor-toolbar-actions'
-import { BiddingPreQuoteVendorEditDialog } from './bidding-pre-quote-vendor-edit-dialog'
-import { getBiddingPreQuoteVendorColumns, BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { Bidding } from '@/db/schema'
-import {
- deleteBiddingCompany
-} from '../service'
-import { getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
-import { BiddingPreQuoteItemDetailsDialog } from './bidding-pre-quote-item-details-dialog'
-import { BiddingPreQuoteAttachmentsDialog } from './bidding-pre-quote-attachments-dialog'
-import { getPrItemsForBidding } from '../service'
-
-interface BiddingPreQuoteVendorTableContentProps {
- biddingId: number
- bidding: Bidding
- biddingCompanies: BiddingCompany[]
- onRefresh: () => void
- onOpenItemsDialog: () => void
- onOpenTargetPriceDialog: () => void
- onOpenSelectionReasonDialog: () => void
- onEdit?: (company: BiddingCompany) => void
- onDelete?: (company: BiddingCompany) => void
-}
-
-const filterFields: DataTableFilterField<BiddingCompany>[] = [
- {
- id: 'companyName',
- label: '업체명',
- placeholder: '업체명으로 검색...',
- },
- {
- id: 'companyCode',
- label: '업체코드',
- placeholder: '업체코드로 검색...',
- },
- {
- id: 'contactPerson',
- label: '담당자',
- placeholder: '담당자로 검색...',
- },
-]
-
-const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [
- {
- id: 'companyName',
- label: '업체명',
- type: 'text',
- },
- {
- id: 'companyCode',
- label: '업체코드',
- type: 'text',
- },
- {
- id: 'contactPerson',
- label: '담당자',
- type: 'text',
- },
- {
- id: 'preQuoteAmount',
- label: '사전견적금액',
- type: 'number',
- },
- {
- id: 'invitationStatus',
- label: '초대 상태',
- type: 'multi-select',
- options: [
- { label: '수락', value: 'accepted' },
- { label: '거절', value: 'declined' },
- { label: '요청됨', value: 'sent' },
- { label: '대기중', value: 'pending' },
- ],
- },
-]
-
-export function BiddingPreQuoteVendorTableContent({
- biddingId,
- bidding,
- biddingCompanies,
- onRefresh,
- onOpenItemsDialog,
- onOpenTargetPriceDialog,
- onOpenSelectionReasonDialog,
- onEdit,
- onDelete
-}: BiddingPreQuoteVendorTableContentProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null)
- const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
- const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
- const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
- const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false)
- const [selectedCompanyForDetails, setSelectedCompanyForDetails] = React.useState<BiddingCompany | null>(null)
- const [prItems, setPrItems] = React.useState<any[]>([])
- const [isAttachmentsDialogOpen, setIsAttachmentsDialogOpen] = React.useState(false)
- const [selectedCompanyForAttachments, setSelectedCompanyForAttachments] = React.useState<BiddingCompany | null>(null)
-
- const handleDelete = (company: BiddingCompany) => {
- startTransition(async () => {
- const response = await deleteBiddingCompany(company.id)
-
- if (response.success) {
- toast({
- title: '성공',
- description: response.message,
- })
- onRefresh()
- } else {
- toast({
- title: '오류',
- description: response.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleEdit = (company: BiddingCompany) => {
- setSelectedCompany(company)
- setIsEditDialogOpen(true)
- }
-
-
- const handleViewPriceAdjustment = async (company: BiddingCompany) => {
- startTransition(async () => {
- const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(company.id)
- if (priceAdjustmentForm) {
- setPriceAdjustmentData(priceAdjustmentForm)
- setSelectedCompany(company)
- setIsPriceAdjustmentDialogOpen(true)
- } else {
- toast({
- title: '정보 없음',
- description: '연동제 정보가 없습니다.',
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleViewItemDetails = async (company: BiddingCompany) => {
- startTransition(async () => {
- try {
- // PR 아이템 정보 로드
- const prItemsData = await getPrItemsForBidding(biddingId)
- setPrItems(prItemsData)
- setSelectedCompanyForDetails(company)
- setIsItemDetailsDialogOpen(true)
- } catch (error) {
- console.error('Failed to load PR items:', error)
- toast({
- title: '오류',
- description: '품목 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleViewAttachments = (company: BiddingCompany) => {
- setSelectedCompanyForAttachments(company)
- setIsAttachmentsDialogOpen(true)
- }
-
- const columns = React.useMemo(
- () => getBiddingPreQuoteVendorColumns({
- onEdit: onEdit || handleEdit,
- onDelete: onDelete || handleDelete,
- onViewPriceAdjustment: handleViewPriceAdjustment,
- onViewItemDetails: handleViewItemDetails,
- onViewAttachments: handleViewAttachments
- }),
- [onEdit, onDelete, handleEdit, handleDelete, handleViewPriceAdjustment, handleViewItemDetails, handleViewAttachments]
- )
-
- const { table } = useDataTable({
- data: biddingCompanies,
- columns,
- pageCount: 1,
- filterFields,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: 'companyName', desc: false }],
- columnPinning: { right: ['actions'] },
- },
- getRowId: (originalRow) => originalRow.id.toString(),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <BiddingPreQuoteVendorToolbarActions
- table={table}
- biddingId={biddingId}
- bidding={bidding}
- biddingCompanies={biddingCompanies}
- onOpenItemsDialog={onOpenItemsDialog}
- onOpenTargetPriceDialog={onOpenTargetPriceDialog}
- onOpenSelectionReasonDialog={onOpenSelectionReasonDialog}
- onSuccess={onRefresh}
- />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- <BiddingPreQuoteVendorEditDialog
- company={selectedCompany}
- open={isEditDialogOpen}
- onOpenChange={setIsEditDialogOpen}
- onSuccess={onRefresh}
- />
-
- <PriceAdjustmentDialog
- open={isPriceAdjustmentDialogOpen}
- onOpenChange={setIsPriceAdjustmentDialogOpen}
- data={priceAdjustmentData}
- vendorName={selectedCompany?.companyName || ''}
- />
-
- <BiddingPreQuoteItemDetailsDialog
- open={isItemDetailsDialogOpen}
- onOpenChange={setIsItemDetailsDialogOpen}
- biddingId={biddingId}
- biddingCompanyId={selectedCompanyForDetails?.id || 0}
- companyName={selectedCompanyForDetails?.companyName || ''}
- prItems={prItems}
- currency={bidding.currency || 'KRW'}
- />
-
- <BiddingPreQuoteAttachmentsDialog
- open={isAttachmentsDialogOpen}
- onOpenChange={setIsAttachmentsDialogOpen}
- biddingId={biddingId}
- companyId={selectedCompanyForAttachments?.companyId || 0}
- companyName={selectedCompanyForAttachments?.companyName || ''}
- />
- </>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
deleted file mode 100644
index 34e53fb2..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { useTransition } from "react"
-import { Button } from "@/components/ui/button"
-import { Plus, Send, Mail, CheckSquare } from "lucide-react"
-import { BiddingCompany } from "./bidding-pre-quote-vendor-columns"
-import { BiddingPreQuoteVendorCreateDialog } from "./bidding-pre-quote-vendor-create-dialog"
-import { BiddingPreQuoteInvitationDialog } from "./bidding-pre-quote-invitation-dialog"
-import { BiddingPreQuoteSelectionDialog } from "./bidding-pre-quote-selection-dialog"
-import { Bidding } from "@/db/schema"
-import { useToast } from "@/hooks/use-toast"
-
-interface BiddingPreQuoteVendorToolbarActionsProps {
- table: Table<BiddingCompany>
- biddingId: number
- bidding: Bidding
- biddingCompanies: BiddingCompany[]
- onOpenItemsDialog: () => void
- onOpenTargetPriceDialog: () => void
- onOpenSelectionReasonDialog: () => void
- onSuccess: () => void
-}
-
-export function BiddingPreQuoteVendorToolbarActions({
- table,
- biddingId,
- bidding,
- biddingCompanies,
- onOpenItemsDialog,
- onOpenTargetPriceDialog,
- onOpenSelectionReasonDialog,
- onSuccess
-}: BiddingPreQuoteVendorToolbarActionsProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
- const [isInvitationDialogOpen, setIsInvitationDialogOpen] = React.useState(false)
- const [isSelectionDialogOpen, setIsSelectionDialogOpen] = React.useState(false)
-
- const handleCreateCompany = () => {
- setIsCreateDialogOpen(true)
- }
-
- const handleSendInvitations = () => {
- setIsInvitationDialogOpen(true)
- }
-
- const handleManageSelection = () => {
- const selectedRows = table.getFilteredSelectedRowModel().rows
- if (selectedRows.length === 0) {
- toast({
- title: '선택 필요',
- description: '본입찰 선정 상태를 변경할 업체를 선택해주세요.',
- variant: 'destructive',
- })
- return
- }
- setIsSelectionDialogOpen(true)
- }
-
-
-
- return (
- <>
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleCreateCompany}
- disabled={isPending}
- >
- <Plus className="mr-2 h-4 w-4" />
- 업체 추가
- </Button>
-
- <Button
- variant="default"
- size="sm"
- onClick={handleSendInvitations}
- disabled={isPending}
- >
- <Mail className="mr-2 h-4 w-4" />
- 초대 발송
- </Button>
-
- <Button
- variant="secondary"
- size="sm"
- onClick={handleManageSelection}
- disabled={isPending}
- >
- <CheckSquare className="mr-2 h-4 w-4" />
- 본입찰 선정
- </Button>
- </div>
-
- <BiddingPreQuoteVendorCreateDialog
- biddingId={biddingId}
- open={isCreateDialogOpen}
- onOpenChange={setIsCreateDialogOpen}
- onSuccess={() => {
- onSuccess()
- setIsCreateDialogOpen(false)
- }}
- />
-
- <BiddingPreQuoteInvitationDialog
- open={isInvitationDialogOpen}
- onOpenChange={setIsInvitationDialogOpen}
- companies={biddingCompanies}
- biddingId={biddingId}
- biddingTitle={bidding.title}
- projectName={bidding.projectName}
- onSuccess={onSuccess}
- />
-
- <BiddingPreQuoteSelectionDialog
- open={isSelectionDialogOpen}
- onOpenChange={setIsSelectionDialogOpen}
- selectedCompanies={table.getFilteredSelectedRowModel().rows.map(row => row.original)}
- onSuccess={() => {
- onSuccess()
- table.resetRowSelection()
- }}
- />
- </>
- )
-}