diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-27 12:06:26 +0000 |
| commit | 7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch) | |
| tree | 8e66703ec821888ad51dcc242a508813a027bf71 /lib/bidding/detail | |
| parent | 7eac558470ef179dad626a8e82db5784fe86a556 (diff) | |
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'lib/bidding/detail')
11 files changed, 3056 insertions, 0 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts new file mode 100644 index 00000000..d0dc6a08 --- /dev/null +++ b/lib/bidding/detail/service.ts @@ -0,0 +1,970 @@ +'use server' + +import db from '@/db/db' +import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions } from '@/db/schema' +import { eq, and, sql, desc, ne } from 'drizzle-orm' +import { revalidatePath } from 'next/cache' + +// 데이터 조회 함수들 +export interface BiddingDetailData { + bidding: Awaited<ReturnType<typeof getBiddingById>> + quotationDetails: QuotationDetails | null + quotationVendors: QuotationVendor[] + biddingCompanies: Awaited<ReturnType<typeof getBiddingCompaniesData>> + prItems: Awaited<ReturnType<typeof getPRItemsForBidding>> +} + +// getBiddingById 함수 임포트 (기존 함수 재사용) +import { getBiddingById, getPRDetailsAction } from '@/lib/bidding/service' + +// Promise.all을 사용하여 모든 데이터를 병렬로 조회 +export async function getBiddingDetailData(biddingId: number): Promise<BiddingDetailData> { + const [ + bidding, + quotationDetails, + quotationVendors, + biddingCompanies, + prItems + ] = await Promise.all([ + getBiddingById(biddingId), + getQuotationDetails(biddingId), + getQuotationVendors(biddingId), + getBiddingCompaniesData(biddingId), + getPRItemsForBidding(biddingId) + ]) + + return { + bidding, + quotationDetails, + quotationVendors, + biddingCompanies, + prItems + } +} +export interface QuotationDetails { + biddingId: number + estimatedPrice: number // 예상액 + lowestQuote: number // 최저견적가 + averageQuote: number // 평균견적가 + targetPrice: number // 내정가 + quotationCount: number // 견적 수 + lastUpdated: string // 최종 업데이트일 +} + +export interface QuotationVendor { + id: number + biddingId: number + vendorId: number + vendorName: string + vendorCode: string + contactPerson: string + contactEmail: string + contactPhone: string + quotationAmount: number // 견적금액 + currency: string + paymentTerms: string // 지급조건 (응답) + taxConditions: string // 세금조건 (응답) + deliveryDate: string // 납품일 (응답) + submissionDate: string // 제출일 + isWinner: boolean // 낙찰여부 + awardRatio: number // 발주비율 + status: 'pending' | 'submitted' | 'selected' | 'rejected' + // bidding_conditions에서 제시된 조건들 + offeredPaymentTerms?: string // 제시된 지급조건 + offeredTaxConditions?: string // 제시된 세금조건 + offeredIncoterms?: string // 제시된 운송조건 + offeredContractDeliveryDate?: string // 제시된 계약납기일 + offeredShippingPort?: string // 제시된 선적지 + offeredDestinationPort?: string // 제시된 도착지 + isPriceAdjustmentApplicable?: boolean // 연동제 적용 여부 + documents: Array<{ + id: number + fileName: string + originalFileName: string + filePath: string + uploadedAt: string + }> +} + +// 견적 시스템에서 내정가 및 관련 정보를 가져오는 함수 +export async function getQuotationDetails(biddingId: number): Promise<QuotationDetails | null> { + try { + // bidding_companies 테이블에서 견적 데이터를 집계 + const quotationStats = await db + .select({ + biddingId: biddingCompanies.biddingId, + estimatedPrice: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('estimated_price'), + lowestQuote: sql<number>`MIN(${biddingCompanies.finalQuoteAmount})`.as('lowest_quote'), + averageQuote: sql<number>`AVG(${biddingCompanies.finalQuoteAmount})`.as('average_quote'), + targetPrice: sql<number>`AVG(${biddings.targetPrice})`.as('target_price'), + quotationCount: sql<number>`COUNT(*)`.as('quotation_count'), + lastUpdated: sql<string>`MAX(${biddingCompanies.updatedAt})`.as('last_updated') + }) + .from(biddingCompanies) + .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + sql`${biddingCompanies.finalQuoteAmount} IS NOT NULL` + )) + .groupBy(biddingCompanies.biddingId) + .limit(1) + + if (quotationStats.length === 0) { + return { + biddingId, + estimatedPrice: 0, + lowestQuote: 0, + averageQuote: 0, + targetPrice: 0, + quotationCount: 0, + lastUpdated: new Date().toISOString() + } + } + + const stat = quotationStats[0] + + return { + biddingId, + estimatedPrice: Number(stat.estimatedPrice) || 0, + lowestQuote: Number(stat.lowestQuote) || 0, + averageQuote: Number(stat.averageQuote) || 0, + targetPrice: Number(stat.targetPrice) || 0, + quotationCount: Number(stat.quotationCount) || 0, + lastUpdated: stat.lastUpdated || new Date().toISOString() + } + } catch (error) { + console.error('Failed to get quotation details:', error) + return null + } +} + +// bidding_companies 테이블을 메인으로 vendors 테이블을 조인하여 협력업체 정보 조회 +export async function getBiddingCompaniesData(biddingId: number) { + try { + const companies = await db + .select({ + id: biddingCompanies.id, + biddingId: biddingCompanies.biddingId, + companyId: biddingCompanies.companyId, + companyName: vendors.vendorName, + companyCode: vendors.vendorCode, + invitationStatus: biddingCompanies.invitationStatus, + invitedAt: biddingCompanies.invitedAt, + respondedAt: biddingCompanies.respondedAt, + preQuoteAmount: biddingCompanies.preQuoteAmount, + preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt, + isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt, + isWinner: biddingCompanies.isWinner, + notes: biddingCompanies.notes, + contactPerson: biddingCompanies.contactPerson, + contactEmail: biddingCompanies.contactEmail, + contactPhone: biddingCompanies.contactPhone, + createdAt: biddingCompanies.createdAt, + updatedAt: biddingCompanies.updatedAt + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .where(eq(biddingCompanies.biddingId, biddingId)) + .orderBy(desc(biddingCompanies.finalQuoteAmount)) + + return companies + } catch (error) { + console.error('Failed to get bidding companies data:', error) + return [] + } +} + +// prItemsForBidding 테이블에서 품목 정보 조회 +export async function getPRItemsForBidding(biddingId: number) { + try { + const items = await db + .select() + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + .orderBy(prItemsForBidding.id) + + return items + } catch (error) { + console.error('Failed to get PR items for bidding:', error) + return [] + } +} + +// 견적 시스템에서 협력업체 정보를 가져오는 함수 +export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> { + try { + // bidding_companies 테이블을 메인으로 vendors, bidding_conditions, company_condition_responses를 조인하여 협력업체 정보 조회 + const vendorsData = await db + .select({ + id: biddingCompanies.id, + biddingId: biddingCompanies.biddingId, + vendorId: biddingCompanies.companyId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + contactPerson: biddingCompanies.contactPerson, + contactEmail: biddingCompanies.contactEmail, + contactPhone: biddingCompanies.contactPhone, + quotationAmount: biddingCompanies.finalQuoteAmount, + currency: sql<string>`'KRW'` as currency, + paymentTerms: sql<string>`COALESCE(${companyConditionResponses.paymentTermsResponse}, '')`, + taxConditions: sql<string>`COALESCE(${companyConditionResponses.taxConditionsResponse}, '')`, + deliveryDate: companyConditionResponses.proposedContractDeliveryDate, + submissionDate: biddingCompanies.finalQuoteSubmittedAt, + isWinner: biddingCompanies.isWinner, + awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`, + status: sql<string>`CASE + WHEN ${biddingCompanies.isWinner} THEN 'selected' + WHEN ${biddingCompanies.finalQuoteSubmittedAt} IS NOT NULL THEN 'submitted' + WHEN ${biddingCompanies.respondedAt} IS NOT NULL THEN 'submitted' + ELSE 'pending' + END`, + // bidding_conditions에서 제시된 조건들 + offeredPaymentTerms: biddingConditions.paymentTerms, + offeredTaxConditions: biddingConditions.taxConditions, + offeredIncoterms: biddingConditions.incoterms, + offeredContractDeliveryDate: biddingConditions.contractDeliveryDate, + offeredShippingPort: biddingConditions.shippingPort, + offeredDestinationPort: biddingConditions.destinationPort, + isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable, + }) + .from(biddingCompanies) + .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) + .leftJoin(companyConditionResponses, eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)) + .leftJoin(biddingConditions, eq(biddingCompanies.id, biddingConditions.biddingCompanyId)) + .where(eq(biddingCompanies.biddingId, biddingId)) + .orderBy(desc(biddingCompanies.finalQuoteAmount)) + + return vendorsData.map(vendor => ({ + id: vendor.id, + biddingId: vendor.biddingId, + vendorId: vendor.vendorId, + vendorName: vendor.vendorName || `Vendor ${vendor.vendorId}`, + vendorCode: vendor.vendorCode || '', + contactPerson: vendor.contactPerson || '', + contactEmail: vendor.contactEmail || '', + contactPhone: vendor.contactPhone || '', + quotationAmount: Number(vendor.quotationAmount) || 0, + currency: vendor.currency, + paymentTerms: vendor.paymentTerms, + taxConditions: vendor.taxConditions, + deliveryDate: vendor.deliveryDate ? vendor.deliveryDate.toISOString().split('T')[0] : '', + submissionDate: vendor.submissionDate ? vendor.submissionDate.toISOString().split('T')[0] : '', + isWinner: vendor.isWinner || false, + awardRatio: vendor.awardRatio || 0, + status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected', + // bidding_conditions에서 제시된 조건들 + offeredPaymentTerms: vendor.offeredPaymentTerms, + offeredTaxConditions: vendor.offeredTaxConditions, + offeredIncoterms: vendor.offeredIncoterms, + offeredContractDeliveryDate: vendor.offeredContractDeliveryDate ? vendor.offeredContractDeliveryDate.toISOString().split('T')[0] : undefined, + offeredShippingPort: vendor.offeredShippingPort, + offeredDestinationPort: vendor.offeredDestinationPort, + isPriceAdjustmentApplicable: vendor.isPriceAdjustmentApplicable, + documents: [] // TODO: 문서 정보 조회 로직 추가 + })) + } catch (error) { + console.error('Failed to get quotation vendors:', error) + return [] + } +} + +// 내정가 수동 업데이트 (실제 저장) +export async function updateTargetPrice( + biddingId: number, + targetPrice: number, + targetPriceCalculationCriteria: string, + userId: string +) { + try { + await db + .update(biddings) + .set({ + targetPrice: targetPrice.toString(), + targetPriceCalculationCriteria: targetPriceCalculationCriteria, + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + + revalidatePath(`/evcp/bid/${biddingId}`) + return { success: true, message: '내정가가 성공적으로 업데이트되었습니다.' } + } catch (error) { + console.error('Failed to update target price:', error) + return { success: false, error: '내정가 업데이트에 실패했습니다.' } + } +} + +// 협력업체 정보 저장 - biddingCompanies와 biddingConditions 테이블에 레코드 생성 +export async function createQuotationVendor(input: Omit<QuotationVendor, 'id'>, userId: string) { + try { + const result = await db.transaction(async (tx) => { + // 1. biddingCompanies에 레코드 생성 + const biddingCompanyResult = await tx.insert(biddingCompanies).values({ + biddingId: input.biddingId, + companyId: input.vendorId, + vendorId: input.vendorId, + quotationAmount: input.quotationAmount, + currency: input.currency, + status: input.status, + awardRatio: input.awardRatio, + isWinner: false, + contactPerson: input.contactPerson, + contactEmail: input.contactEmail, + contactPhone: input.contactPhone, + paymentTerms: input.paymentTerms, + taxConditions: input.taxConditions, + deliveryDate: input.deliveryDate ? new Date(input.deliveryDate) : null, + submissionDate: new Date(), + createdBy: userId, + updatedBy: userId, + }).returning({ id: biddingCompanies.id }) + + if (biddingCompanyResult.length === 0) { + throw new Error('협력업체 정보 저장에 실패했습니다.') + } + + const biddingCompanyId = biddingCompanyResult[0].id + + // 2. biddingConditions에 기본 조건 생성 + await tx.insert(biddingConditions).values({ + biddingCompanyId: biddingCompanyId, + paymentTerms: '["선금 30%, 잔금 70%"]', // 기본 지급조건 + taxConditions: '["부가세 별도"]', // 기본 세금조건 + contractDeliveryDate: null, + isPriceAdjustmentApplicable: false, + incoterms: '["FOB"]', // 기본 운송조건 + shippingPort: null, + destinationPort: null, + sparePartOptions: '[]', // 기본 예비품 옵션 + createdAt: new Date(), + updatedAt: new Date(), + }) + + return biddingCompanyId + }) + + revalidatePath(`/evcp/bid/[id]`) + return { + success: true, + message: '협력업체 정보가 성공적으로 저장되었습니다.', + data: { id: result } + } + } catch (error) { + console.error('Failed to create quotation vendor:', error) + return { success: false, error: '협력업체 정보 저장에 실패했습니다.' } + } +} + +// 협력업체 정보 업데이트 +export async function updateQuotationVendor(id: number, input: Partial<QuotationVendor>, userId: string) { + try { + const result = await db.transaction(async (tx) => { + // 1. biddingCompanies 테이블 업데이트 + const updateData: any = {} + if (input.quotationAmount !== undefined) updateData.finalQuoteAmount = input.quotationAmount + 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.awardRatio !== undefined) updateData.awardRatio = input.awardRatio + if (input.status !== undefined) updateData.status = input.status + updateData.updatedBy = userId + updateData.updatedAt = new Date() + + if (Object.keys(updateData).length > 0) { + await tx.update(biddingCompanies) + .set(updateData) + .where(eq(biddingCompanies.id, id)) + } + + // 2. biddingConditions 테이블 업데이트 (제시된 조건들) + if (input.offeredPaymentTerms !== undefined || + input.offeredTaxConditions !== undefined || + input.offeredIncoterms !== undefined || + input.offeredContractDeliveryDate !== undefined || + input.offeredShippingPort !== undefined || + input.offeredDestinationPort !== undefined || + input.isPriceAdjustmentApplicable !== undefined) { + + const conditionsUpdateData: any = {} + if (input.offeredPaymentTerms !== undefined) conditionsUpdateData.paymentTerms = input.offeredPaymentTerms + if (input.offeredTaxConditions !== undefined) conditionsUpdateData.taxConditions = input.offeredTaxConditions + if (input.offeredIncoterms !== undefined) conditionsUpdateData.incoterms = input.offeredIncoterms + if (input.offeredContractDeliveryDate !== undefined) conditionsUpdateData.contractDeliveryDate = input.offeredContractDeliveryDate ? new Date(input.offeredContractDeliveryDate) : null + if (input.offeredShippingPort !== undefined) conditionsUpdateData.shippingPort = input.offeredShippingPort + if (input.offeredDestinationPort !== undefined) conditionsUpdateData.destinationPort = input.offeredDestinationPort + if (input.isPriceAdjustmentApplicable !== undefined) conditionsUpdateData.isPriceAdjustmentApplicable = input.isPriceAdjustmentApplicable + conditionsUpdateData.updatedAt = new Date() + + await tx.update(biddingConditions) + .set(conditionsUpdateData) + .where(eq(biddingConditions.biddingCompanyId, id)) + } + + return true + }) + + revalidatePath(`/evcp/bid/[id]`) + return { + success: true, + message: '협력업체 정보가 성공적으로 업데이트되었습니다.', + } + } catch (error) { + console.error('Failed to update quotation vendor:', error) + return { success: false, error: '협력업체 정보 업데이트에 실패했습니다.' } + } +} + +// 협력업체 정보 삭제 +export async function deleteQuotationVendor(id: number) { + try { + // TODO: 실제로는 견적 시스템의 테이블에서 삭제 + console.log(`[TODO] 견적 시스템에서 협력업체 정보 ${id} 삭제 예정`) + + // 임시로 성공 응답 + return { success: true, message: '협력업체 정보가 성공적으로 삭제되었습니다.' } + } catch (error) { + console.error('Failed to delete quotation vendor:', error) + return { success: false, error: '협력업체 정보 삭제에 실패했습니다.' } + } +} + +// 낙찰 처리 +export async function selectWinner(biddingId: number, vendorId: number, awardRatio: number, userId: string) { + try { + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 기존 낙찰자 초기화 + await tx + .update(biddingCompanies) + .set({ + isWinner: false, + updatedAt: new Date() + }) + .where(eq(biddingCompanies.biddingId, biddingId)) + + // 새로운 낙찰자 설정 + const biddingCompany = await tx + .select() + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, vendorId) + )) + .limit(1) + + if (biddingCompany.length > 0) { + await tx + .update(biddingCompanies) + .set({ + isWinner: true, + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompany[0].id)) + } + + // biddings 테이블의 상태 업데이트 + await tx + .update(biddings) + .set({ + status: 'vendor_selected', + finalBidPrice: undefined, // TODO: 낙찰가 설정 로직 추가 + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + }) + + revalidatePath(`/evcp/bid/${biddingId}`) + return { success: true, message: '낙찰 처리가 완료되었습니다.' } + } catch (error) { + console.error('Failed to select winner:', error) + return { success: false, error: '낙찰 처리에 실패했습니다.' } + } +} + +// 유찰 처리 +export async function markAsDisposal(biddingId: number, userId: string) { + try { + await db + .update(biddings) + .set({ + status: 'bidding_disposal', + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + + revalidatePath(`/evcp/bid/${biddingId}`) + return { success: true, message: '유찰 처리가 완료되었습니다.' } + } catch (error) { + console.error('Failed to mark as disposal:', error) + return { success: false, error: '유찰 처리에 실패했습니다.' } + } +} + +// 입찰 등록 (상태 변경) +export async function registerBidding(biddingId: number, userId: string) { + try { + await db + .update(biddings) + .set({ + status: 'bidding_opened', + updatedAt: new Date() + }) + .where(eq(biddings.id, biddingId)) + //todo 입찰 등록하면 bidding_companies invitationStatus를 sent로 변경! + await db + .update(biddingCompanies) + .set({ + invitationStatus: 'sent', + updatedAt: new Date() + }) + .where(eq(biddingCompanies.biddingId, biddingId)) + + revalidatePath(`/evcp/bid/${biddingId}`) + return { success: true, message: '입찰이 성공적으로 등록되었습니다.' } + } catch (error) { + console.error('Failed to register bidding:', error) + return { success: false, error: '입찰 등록에 실패했습니다.' } + } +} + +// 재입찰 생성 +export async function createRebidding(originalBiddingId: number, userId: string) { + try { + // 원본 입찰 정보 조회 + const originalBidding = await db + .select() + .from(biddings) + .where(eq(biddings.id, originalBiddingId)) + .limit(1) + + if (originalBidding.length === 0) { + return { success: false, error: '원본 입찰을 찾을 수 없습니다.' } + } + + const original = originalBidding[0] + + // 재입찰용 데이터 준비 + const rebiddingData = { + ...original, + id: undefined, + biddingNumber: `${original.biddingNumber}-R${(original.revision || 0) + 1}`, + revision: (original.revision || 0) + 1, + status: 'bidding_generated' as const, + createdAt: new Date(), + updatedAt: new Date() + } + + // 새로운 입찰 생성 + const [newBidding] = await db + .insert(biddings) + .values(rebiddingData) + .returning({ id: biddings.id, biddingNumber: biddings.biddingNumber }) + + revalidatePath('/evcp/bid') + revalidatePath(`/evcp/bid/${newBidding.id}`) + + return { + success: true, + message: '재입찰이 성공적으로 생성되었습니다.', + data: newBidding + } + } catch (error) { + console.error('Failed to create rebidding:', error) + return { success: false, error: '재입찰 생성에 실패했습니다.' } + } +} + +// 업체 선정 사유 업데이트 +export async function updateVendorSelectionReason(biddingId: number, selectedCompanyId: number, selectionReason: string, userId: string) { + try { + // vendorSelectionResults 테이블에 삽입 또는 업데이트 + await db + .insert(vendorSelectionResults) + .values({ + biddingId, + selectedCompanyId, + selectionReason, + selectedBy: userId, + selectedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + .onConflictDoUpdate({ + target: [vendorSelectionResults.biddingId], + set: { + selectedCompanyId, + selectionReason, + selectedBy: userId, + selectedAt: new Date(), + updatedAt: new Date() + } + }) + + revalidatePath(`/evcp/bid/${biddingId}`) + return { success: true, message: '업체 선정 사유가 성공적으로 업데이트되었습니다.' } + } catch (error) { + console.error('Failed to update vendor selection reason:', error) + return { success: false, error: '업체 선정 사유 업데이트에 실패했습니다.' } + } +} + +// PR 품목 정보 업데이트 +export async function updatePrItem(prItemId: number, input: Partial<typeof prItemsForBidding.$inferSelect>, userId: string) { + try { + await db + .update(prItemsForBidding) + .set({ + ...input, + updatedAt: new Date() + }) + .where(eq(prItemsForBidding.id, prItemId)) + + revalidatePath(`/evcp/bid/${input.biddingId}`) + return { success: true, message: '품목 정보가 성공적으로 업데이트되었습니다.' } + } catch (error) { + console.error('Failed to update PR item:', error) + return { success: false, error: '품목 정보 업데이트에 실패했습니다.' } + } +} + +// 입찰에 협력업체 추가 +export async function addVendorToBidding(biddingId: number, companyId: number, userId: string) { + try { + // 이미 추가된 업체인지 확인 + const existing = await db + .select() + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, companyId) + )) + .limit(1) + + if (existing.length > 0) { + return { success: false, error: '이미 추가된 협력업체입니다.' } + } + + // 새로운 협력업체 추가 + await db + .insert(biddingCompanies) + .values({ + biddingId, + companyId, + invitationStatus: 'pending', + invitedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date() + }) + + revalidatePath(`/evcp/bid/${biddingId}`) + return { success: true, message: '협력업체가 성공적으로 추가되었습니다.' } + } catch (error) { + console.error('Failed to add vendor to bidding:', error) + return { success: false, error: '협력업체 추가에 실패했습니다.' } + } +} + +// ================================================= +// 협력업체 페이지용 함수들 (Partners) +// ================================================= + +// 협력업체용 입찰 목록 조회 (bidding_companies 기준) +export interface PartnersBiddingListItem { + // bidding_companies 정보 + id: number + biddingCompanyId: number + invitationStatus: string + respondedAt: string | null + finalQuoteAmount: number | null + finalQuoteSubmittedAt: string | null + isWinner: boolean | null + isAttendingMeeting: boolean | null + notes: string | null + createdAt: Date + updatedAt: Date + updatedBy: string | null + + // biddings 정보 + biddingId: number + biddingNumber: string + revision: number + projectName: string + itemName: string + title: string + contractType: string + biddingType: string + contractPeriod: string | null + submissionStartDate: Date | null + submissionEndDate: Date | null + status: string + managerName: string | null + managerEmail: string | null + managerPhone: string | null + currency: string + budget: number | null + + // 계산된 필드 + responseDeadline: Date | null // 참여회신 마감일 (submissionStartDate 전 3일) + submissionDate: Date | null // 입찰제출일 (submissionEndDate) +} + +export async function getBiddingListForPartners(companyId: number): Promise<PartnersBiddingListItem[]> { + try { + const result = await db + .select({ + // bidding_companies 정보 + id: biddingCompanies.id, + biddingCompanyId: biddingCompanies.id, // 동일 + invitationStatus: biddingCompanies.invitationStatus, + respondedAt: biddingCompanies.respondedAt, + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt, + isWinner: biddingCompanies.isWinner, + isAttendingMeeting: biddingCompanies.isAttendingMeeting, + notes: biddingCompanies.notes, + createdAt: biddingCompanies.createdAt, + updatedAt: biddingCompanies.updatedAt, + updatedBy: biddingCompanies.updatedBy, + + // biddings 정보 + biddingId: biddings.id, + biddingNumber: biddings.biddingNumber, + revision: biddings.revision, + projectName: biddings.projectName, + itemName: biddings.itemName, + title: biddings.title, + contractType: biddings.contractType, + biddingType: biddings.biddingType, + contractPeriod: biddings.contractPeriod, + submissionStartDate: biddings.submissionStartDate, + submissionEndDate: biddings.submissionEndDate, + status: biddings.status, + managerName: biddings.managerName, + managerEmail: biddings.managerEmail, + managerPhone: biddings.managerPhone, + currency: biddings.currency, + budget: biddings.budget, + }) + .from(biddingCompanies) + .innerJoin(biddings, eq(biddingCompanies.biddingId, biddings.id)) + .where(and( + eq(biddingCompanies.companyId, companyId), + ne(biddingCompanies.invitationStatus, 'pending') // 초대 대기 상태 제외 + )) + .orderBy(desc(biddingCompanies.createdAt)) + + // console.log(result) + + // 계산된 필드 추가 + const resultWithCalculatedFields = result.map(item => ({ + ...item, + responseDeadline: item.submissionStartDate + ? new Date(item.submissionStartDate.getTime() - 3 * 24 * 60 * 60 * 1000) // 3일 전 + : null, + submissionDate: item.submissionEndDate, + })) + + return resultWithCalculatedFields + } catch (error) { + console.error('Failed to get bidding list for partners:', error) + return [] + } +} + +// 협력업체용 입찰 상세 정보 조회 +export async function getBiddingDetailsForPartners(biddingId: number, companyId: number) { + try { + const result = 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, + contractPeriod: biddings.contractPeriod, + + // 일정 정보 + 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, + + // 협력업체 특정 정보 + biddingCompanyId: biddingCompanies.id, + invitationStatus: biddingCompanies.invitationStatus, + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt, + isWinner: biddingCompanies.isWinner, + + // 제시된 조건들 (bidding_conditions) + offeredPaymentTerms: biddingConditions.paymentTerms, + offeredTaxConditions: biddingConditions.taxConditions, + offeredIncoterms: biddingConditions.incoterms, + offeredContractDeliveryDate: biddingConditions.contractDeliveryDate, + offeredShippingPort: biddingConditions.shippingPort, + offeredDestinationPort: biddingConditions.destinationPort, + isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable, + + // 응답한 조건들 (company_condition_responses) + responsePaymentTerms: companyConditionResponses.paymentTermsResponse, + responseTaxConditions: companyConditionResponses.taxConditionsResponse, + responseIncoterms: companyConditionResponses.incotermsResponse, + proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate, + proposedShippingPort: companyConditionResponses.proposedShippingPort, + proposedDestinationPort: companyConditionResponses.proposedDestinationPort, + priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse, + additionalProposals: companyConditionResponses.additionalProposals, + responseSubmittedAt: companyConditionResponses.submittedAt, + }) + .from(biddings) + .innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId)) + .leftJoin(biddingConditions, eq(biddingCompanies.id, biddingConditions.biddingCompanyId)) + .leftJoin(companyConditionResponses, eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)) + .where(and( + eq(biddings.id, biddingId), + eq(biddingCompanies.companyId, companyId) + )) + .limit(1) + + return result[0] || null + } catch (error) { + console.error('Failed to get bidding details for partners:', error) + return null + } +} + +// 협력업체 응찰 제출 +export async function submitPartnerResponse( + biddingCompanyId: number, + response: { + paymentTermsResponse?: string + taxConditionsResponse?: string + incotermsResponse?: string + proposedContractDeliveryDate?: string + proposedShippingPort?: string + proposedDestinationPort?: string + priceAdjustmentResponse?: boolean + additionalProposals?: string + finalQuoteAmount?: number + }, + userId: string +) { + try { + const result = await db.transaction(async (tx) => { + // 1. company_condition_responses 테이블에 응답 저장/업데이트 + const responseData = { + paymentTermsResponse: response.paymentTermsResponse, + taxConditionsResponse: response.taxConditionsResponse, + incotermsResponse: response.incotermsResponse, + proposedContractDeliveryDate: response.proposedContractDeliveryDate ? new Date(response.proposedContractDeliveryDate) : null, + proposedShippingPort: response.proposedShippingPort, + proposedDestinationPort: response.proposedDestinationPort, + priceAdjustmentResponse: response.priceAdjustmentResponse, + additionalProposals: response.additionalProposals, + submittedAt: new Date(), + updatedAt: new Date(), + } + + // 기존 응답이 있는지 확인 + const existingResponse = await tx + .select() + .from(companyConditionResponses) + .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + .limit(1) + + if (existingResponse.length > 0) { + // 업데이트 + await tx + .update(companyConditionResponses) + .set(responseData) + .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId)) + } else { + // 새로 생성 + await tx + .insert(companyConditionResponses) + .values({ + biddingCompanyId, + ...responseData, + }) + } + + // 2. biddingCompanies 테이블에 견적 금액과 상태 업데이트 + const companyUpdateData: any = { + respondedAt: new Date(), + updatedAt: new Date(), + updatedBy: userId, + } + + if (response.finalQuoteAmount !== undefined) { + companyUpdateData.finalQuoteAmount = response.finalQuoteAmount + companyUpdateData.finalQuoteSubmittedAt = new Date() + companyUpdateData.invitationStatus = 'submitted' + } + + await tx + .update(biddingCompanies) + .set(companyUpdateData) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + return true + }) + + revalidatePath(`/partners/bid/${biddingId}`) + return { + success: true, + message: '응찰이 성공적으로 제출되었습니다.', + } + } catch (error) { + console.error('Failed to submit partner response:', error) + return { success: false, error: '응찰 제출에 실패했습니다.' } + } +} + +// 사양설명회 참석 여부 업데이트 +export async function updatePartnerAttendance( + biddingCompanyId: number, + isAttending: boolean, + userId: string +) { + try { + await db + .update(biddingCompanies) + .set({ + isAttendingMeeting: isAttending, + updatedAt: new Date(), + updatedBy: userId, + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + revalidatePath('/partners/bid/[id]') + return { + success: true, + message: `사양설명회 ${isAttending ? '참석' : '불참'}으로 설정되었습니다.`, + } + } catch (error) { + console.error('Failed to update partner attendance:', error) + return { success: false, error: '참석 여부 업데이트에 실패했습니다.' } + } +} diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx new file mode 100644 index 00000000..090e7218 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-content.tsx @@ -0,0 +1,93 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' +import { BiddingDetailHeader } from './bidding-detail-header' +import { BiddingDetailVendorTableContent } from './bidding-detail-vendor-table' +import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog' +import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog' +import { BiddingDetailSelectionReasonDialog } from './bidding-detail-selection-reason-dialog' + +interface BiddingDetailContentProps { + bidding: Bidding + quotationDetails: QuotationDetails | null + quotationVendors: QuotationVendor[] + biddingCompanies: any[] + prItems: any[] +} + +export function BiddingDetailContent({ + bidding, + quotationDetails, + quotationVendors, + biddingCompanies, + prItems +}: BiddingDetailContentProps) { + const [dialogStates, setDialogStates] = React.useState({ + items: false, + targetPrice: false, + selectionReason: false + }) + + const [refreshTrigger, setRefreshTrigger] = React.useState(0) + + const handleRefresh = React.useCallback(() => { + setRefreshTrigger(prev => prev + 1) + }, []) + + const openDialog = React.useCallback((type: keyof typeof dialogStates) => { + setDialogStates(prev => ({ ...prev, [type]: true })) + }, []) + + const closeDialog = React.useCallback((type: keyof typeof dialogStates) => { + setDialogStates(prev => ({ ...prev, [type]: false })) + }, []) + + return ( + <div className="container py-6"> + <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow"> + <div className="p-6"> + <BiddingDetailHeader bidding={bidding} /> + + <div className="mt-6"> + <BiddingDetailVendorTableContent + biddingId={bidding.id} + vendors={quotationVendors} + biddingCompanies={biddingCompanies} + onRefresh={handleRefresh} + onOpenItemsDialog={() => openDialog('items')} + onOpenTargetPriceDialog={() => openDialog('targetPrice')} + onOpenSelectionReasonDialog={() => openDialog('selectionReason')} + onEdit={undefined} + onDelete={undefined} + onSelectWinner={undefined} + /> + </div> + </div> + </section> + + <BiddingDetailItemsDialog + open={dialogStates.items} + onOpenChange={(open) => closeDialog('items')} + prItems={prItems} + bidding={bidding} + /> + + <BiddingDetailTargetPriceDialog + open={dialogStates.targetPrice} + onOpenChange={(open) => closeDialog('targetPrice')} + quotationDetails={quotationDetails} + bidding={bidding} + onSuccess={handleRefresh} + /> + + <BiddingDetailSelectionReasonDialog + open={dialogStates.selectionReason} + onOpenChange={(open) => closeDialog('selectionReason')} + bidding={bidding} + onSuccess={handleRefresh} + /> + </div> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-header.tsx b/lib/bidding/detail/table/bidding-detail-header.tsx new file mode 100644 index 00000000..3135f37d --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-header.tsx @@ -0,0 +1,328 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { Bidding, biddingStatusLabels, contractTypeLabels, biddingTypeLabels } from '@/db/schema' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + ArrowLeft, + Send, + RotateCcw, + XCircle, + Calendar, + Building2, + User, + Package, + DollarSign, + Hash +} from 'lucide-react' + +import { formatDate } from '@/lib/utils' +import { + registerBidding, + markAsDisposal, + createRebidding +} from '@/lib/bidding/detail/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailHeaderProps { + bidding: Bidding +} + +export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) { + const router = useRouter() + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + const handleGoBack = () => { + router.push('/evcp/bid') + } + + const handleRegister = () => { + // 상태 검증 + if (bidding.status !== 'bidding_generated') { + toast({ + title: '실행 불가', + description: '입찰 등록은 입찰 생성 상태에서만 가능합니다.', + variant: 'destructive', + }) + return + } + + if (!confirm('입찰을 등록하시겠습니까?')) return + + startTransition(async () => { + const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + router.refresh() + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const handleMarkAsDisposal = () => { + // 상태 검증 + if (bidding.status !== 'bidding_closed') { + toast({ + title: '실행 불가', + description: '유찰 처리는 입찰 마감 상태에서만 가능합니다.', + variant: 'destructive', + }) + return + } + + if (!confirm('입찰을 유찰 처리하시겠습니까?')) return + + startTransition(async () => { + const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + router.refresh() + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const handleCreateRebidding = () => { + // 상태 검증 + if (bidding.status !== 'bidding_disposal') { + toast({ + title: '실행 불가', + description: '재입찰은 유찰 상태에서만 가능합니다.', + variant: 'destructive', + }) + return + } + + if (!confirm('재입찰을 생성하시겠습니까?')) return + + startTransition(async () => { + const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + // 새로 생성된 입찰로 이동 + if (result.data) { + router.push(`/evcp/bid/${result.data.id}`) + } else { + router.refresh() + } + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const getActionButtons = () => { + const buttons = [] + + // 기본 액션 버튼들 (항상 표시) + buttons.push( + <Button + key="back" + variant="outline" + onClick={handleGoBack} + disabled={isPending} + > + <ArrowLeft className="w-4 h-4 mr-2" /> + 목록으로 + </Button> + ) + + // 모든 액션 버튼을 항상 표시 (상태 검증은 각 핸들러에서) + buttons.push( + <Button + key="register" + onClick={handleRegister} + disabled={isPending} + > + <Send className="w-4 h-4 mr-2" /> + 입찰등록 + </Button> + ) + + buttons.push( + <Button + key="disposal" + variant="destructive" + onClick={handleMarkAsDisposal} + disabled={isPending} + > + <XCircle className="w-4 h-4 mr-2" /> + 유찰 + </Button> + ) + + buttons.push( + <Button + key="rebidding" + onClick={handleCreateRebidding} + disabled={isPending} + > + <RotateCcw className="w-4 h-4 mr-2" /> + 재입찰 + </Button> + ) + + return buttons + } + + return ( + <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> + <div className="px-6 py-4"> + {/* 헤더 메인 영역 */} + <div className="flex items-center justify-between mb-4"> + <div className="flex items-center gap-4 flex-1 min-w-0"> + {/* 제목과 배지 */} + <div className="flex items-center gap-3 flex-1 min-w-0"> + <h1 className="text-xl font-semibold truncate">{bidding.title}</h1> + <div className="flex items-center gap-2 flex-shrink-0"> + <Badge variant="outline" className="font-mono text-xs"> + <Hash className="w-3 h-3 mr-1" /> + {bidding.biddingNumber} + {bidding.revision && bidding.revision > 0 && ` Rev.${bidding.revision}`} + </Badge> + <Badge variant={ + bidding.status === 'bidding_disposal' ? 'destructive' : + bidding.status === 'vendor_selected' ? 'default' : + 'secondary' + } className="text-xs"> + {biddingStatusLabels[bidding.status]} + </Badge> + </div> + </div> + + {/* 액션 버튼들 */} + <div className="flex items-center gap-2 flex-shrink-0"> + {getActionButtons()} + </div> + </div> + </div> + + {/* 세부 정보 영역 */} + <div className="flex flex-wrap items-center gap-6 text-sm"> + {/* 프로젝트 정보 */} + {bidding.projectName && ( + <div className="flex items-center gap-1.5 text-muted-foreground"> + <Building2 className="w-4 h-4" /> + <span className="font-medium">프로젝트:</span> + <span>{bidding.projectName}</span> + </div> + )} + + {/* 품목 정보 */} + {bidding.itemName && ( + <div className="flex items-center gap-1.5 text-muted-foreground"> + <Package className="w-4 h-4" /> + <span className="font-medium">품목:</span> + <span>{bidding.itemName}</span> + </div> + )} + + {/* 담당자 정보 */} + {bidding.managerName && ( + <div className="flex items-center gap-1.5 text-muted-foreground"> + <User className="w-4 h-4" /> + <span className="font-medium">담당자:</span> + <span>{bidding.managerName}</span> + </div> + )} + + {/* 계약구분 */} + <div className="flex items-center gap-1.5 text-muted-foreground"> + <span className="font-medium">계약:</span> + <span>{contractTypeLabels[bidding.contractType]}</span> + </div> + + {/* 입찰유형 */} + <div className="flex items-center gap-1.5 text-muted-foreground"> + <span className="font-medium">유형:</span> + <span>{biddingTypeLabels[bidding.biddingType]}</span> + </div> + + {/* 낙찰수 */} + <div className="flex items-center gap-1.5 text-muted-foreground"> + <span className="font-medium">낙찰:</span> + <span>{bidding.awardCount === 'single' ? '단수' : '복수'}</span> + </div> + + {/* 통화 */} + <div className="flex items-center gap-1.5 text-muted-foreground"> + <DollarSign className="w-4 h-4" /> + <span className="font-mono">{bidding.currency}</span> + </div> + + {/* 예산 정보 */} + {bidding.budget && ( + <div className="flex items-center gap-1.5"> + <span className="font-medium text-muted-foreground">예산:</span> + <span className="font-semibold"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: bidding.currency || 'KRW', + }).format(Number(bidding.budget))} + </span> + </div> + )} + </div> + + {/* 일정 정보 */} + {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && ( + <div className="flex flex-wrap items-center gap-4 mt-3 pt-3 border-t border-border/50"> + <Calendar className="w-4 h-4 text-muted-foreground flex-shrink-0" /> + <div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> + {bidding.submissionStartDate && bidding.submissionEndDate && ( + <div> + <span className="font-medium">제출기간:</span> {formatDate(bidding.submissionStartDate, 'KR')} ~ {formatDate(bidding.submissionEndDate, 'KR')} + </div> + )} + {bidding.evaluationDate && ( + <div> + <span className="font-medium">평가일:</span> {formatDate(bidding.evaluationDate, 'KR')} + </div> + )} + {bidding.preQuoteDate && ( + <div> + <span className="font-medium">사전견적일:</span> {formatDate(bidding.preQuoteDate, 'KR')} + </div> + )} + {bidding.biddingRegistrationDate && ( + <div> + <span className="font-medium">입찰등록일:</span> {formatDate(bidding.biddingRegistrationDate, 'KR')} + </div> + )} + </div> + </div> + )} + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/bidding/detail/table/bidding-detail-items-dialog.tsx b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx new file mode 100644 index 00000000..2bab3ef0 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx @@ -0,0 +1,138 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { formatDate } from '@/lib/utils' + +interface PrItem { + id: number + biddingId: number + itemName: string + itemCode: string + specification: string + quantity: number + unit: string + estimatedPrice: number + budget: number + deliveryDate: Date + notes: string + createdAt: Date + updatedAt: Date +} + +interface BiddingDetailItemsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + prItems: PrItem[] + bidding: Bidding +} + +export function BiddingDetailItemsDialog({ + open, + onOpenChange, + prItems, + bidding +}: BiddingDetailItemsDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>품목 정보</DialogTitle> + <DialogDescription> + 입찰번호: {bidding.biddingNumber} - 품목 상세 정보 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">프로젝트:</span> {bidding.projectName || '-'} + </div> + <div> + <span className="font-medium">품목:</span> {bidding.itemName || '-'} + </div> + </div> + + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead>품목코드</TableHead> + <TableHead>품목명</TableHead> + <TableHead>규격</TableHead> + <TableHead className="text-right">수량</TableHead> + <TableHead>단위</TableHead> + <TableHead className="text-right">예상단가</TableHead> + <TableHead className="text-right">예산</TableHead> + <TableHead>납기요청일</TableHead> + <TableHead>비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {prItems.length > 0 ? ( + prItems.map((item) => ( + <TableRow key={item.id}> + <TableCell className="font-mono text-sm"> + {item.itemCode} + </TableCell> + <TableCell className="font-medium"> + {item.itemName} + </TableCell> + <TableCell className="text-sm"> + {item.specification || '-'} + </TableCell> + <TableCell className="text-right"> + {item.quantity ? Number(item.quantity).toLocaleString() : '-'} + </TableCell> + <TableCell>{item.unit}</TableCell> + <TableCell className="text-right font-mono"> + {item.estimatedPrice ? Number(item.estimatedPrice).toLocaleString() : '-'} {bidding.currency} + </TableCell> + <TableCell className="text-right font-mono"> + {item.budget ? Number(item.budget).toLocaleString() : '-'} {bidding.currency} + </TableCell> + <TableCell className="text-sm"> + {item.deliveryDate ? formatDate(item.deliveryDate, 'KR') : '-'} + </TableCell> + <TableCell className="text-sm"> + {item.notes || '-'} + </TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={9} className="text-center py-8"> + 등록된 품목이 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + + {prItems.length > 0 && ( + <div className="text-sm text-muted-foreground"> + 총 {prItems.length}개 품목 + </div> + )} + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx new file mode 100644 index 00000000..0e7ca364 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-selection-reason-dialog.tsx @@ -0,0 +1,167 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { updateVendorSelectionReason } from '@/lib/bidding/detail/service' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailSelectionReasonDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + bidding: Bidding + onSuccess: () => void +} + +export function BiddingDetailSelectionReasonDialog({ + open, + onOpenChange, + bidding, + onSuccess +}: BiddingDetailSelectionReasonDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedCompanyId, setSelectedCompanyId] = React.useState<number | null>(null) + const [selectionReason, setSelectionReason] = React.useState('') + + // 낙찰된 업체 정보 조회 (실제로는 bidding_companies에서 isWinner가 true인 업체를 조회해야 함) + React.useEffect(() => { + if (open) { + // TODO: 실제로는 낙찰된 업체 정보를 조회하여 selectedCompanyId를 설정 + setSelectedCompanyId(null) + setSelectionReason('') + } + }, [open]) + + const handleSave = () => { + if (!selectedCompanyId) { + toast({ + title: '유효성 오류', + description: '선정된 업체를 선택해주세요.', + variant: 'destructive', + }) + return + } + + if (!selectionReason.trim()) { + toast({ + title: '유효성 오류', + description: '선정 사유를 입력해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await updateVendorSelectionReason( + bidding.id, + selectedCompanyId, + selectionReason, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + onSuccess() + onOpenChange(false) + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>업체 선정 사유</DialogTitle> + <DialogDescription> + 입찰번호: {bidding.biddingNumber} - 낙찰 업체 선정 사유 입력 + </DialogDescription> + </DialogHeader> + + <div className="space-y-6"> + {/* 낙찰 정보 */} + <div className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div> + <Label htmlFor="biddingNumber">입찰번호</Label> + <div className="text-sm font-mono mt-1 p-2 bg-muted rounded"> + {bidding.biddingNumber} + </div> + </div> + <div> + <Label htmlFor="projectName">프로젝트명</Label> + <div className="text-sm mt-1 p-2 bg-muted rounded"> + {bidding.projectName || '-'} + </div> + </div> + </div> + </div> + + {/* 선정 업체 선택 */} + <div className="space-y-2"> + <Label htmlFor="selectedCompany">선정된 업체</Label> + <Select + value={selectedCompanyId?.toString() || ''} + onValueChange={(value) => setSelectedCompanyId(Number(value))} + > + <SelectTrigger> + <SelectValue placeholder="선정된 업체를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {/* TODO: 실제로는 낙찰된 업체 목록을 조회하여 표시 */} + <SelectItem value="1">업체 A</SelectItem> + <SelectItem value="2">업체 B</SelectItem> + <SelectItem value="3">업체 C</SelectItem> + </SelectContent> + </Select> + </div> + + {/* 선정 사유 입력 */} + <div className="space-y-2"> + <Label htmlFor="selectionReason">선정 사유</Label> + <Textarea + id="selectionReason" + value={selectionReason} + onChange={(e) => setSelectionReason(e.target.value)} + placeholder="업체 선정 사유를 상세히 입력해주세요." + rows={6} + /> + <div className="text-sm text-muted-foreground"> + 선정 사유는 추후 검토 및 감사에 활용됩니다. 구체적인 선정 기준과 이유를 명확히 기재해주세요. + </div> + </div> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSave} disabled={isPending}> + 저장 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx new file mode 100644 index 00000000..b9dd44dd --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx @@ -0,0 +1,238 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { QuotationDetails, updateTargetPrice } from '@/lib/bidding/detail/service' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailTargetPriceDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + quotationDetails: QuotationDetails | null + bidding: Bidding + onSuccess: () => void +} + +export function BiddingDetailTargetPriceDialog({ + open, + onOpenChange, + quotationDetails, + bidding, + onSuccess +}: BiddingDetailTargetPriceDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [targetPrice, setTargetPrice] = React.useState( + bidding.targetPrice ? Number(bidding.targetPrice) : 0 + ) + const [calculationCriteria, setCalculationCriteria] = React.useState( + (bidding as any).targetPriceCalculationCriteria || '' + ) + + // Dialog가 열릴 때 상태 초기화 + React.useEffect(() => { + if (open) { + setTargetPrice(bidding.targetPrice ? Number(bidding.targetPrice) : 0) + setCalculationCriteria((bidding as any).targetPriceCalculationCriteria || '') + } + }, [open, bidding]) + + const handleSave = () => { + // 필수값 검증 + if (targetPrice <= 0) { + toast({ + title: '유효성 오류', + description: '내정가는 0보다 큰 값을 입력해주세요.', + variant: 'destructive', + }) + return + } + + if (!calculationCriteria.trim()) { + toast({ + title: '유효성 오류', + description: '내정가 산정 기준을 입력해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await updateTargetPrice( + bidding.id, + targetPrice, + calculationCriteria.trim(), + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + onSuccess() + onOpenChange(false) + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: bidding.currency || 'KRW', + }).format(amount) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[800px]"> + <DialogHeader> + <DialogTitle>내정가 산정</DialogTitle> + <DialogDescription> + 입찰번호: {bidding.biddingNumber} - 견적 통계 및 내정가 설정 + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[200px]">항목</TableHead> + <TableHead>값</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {/* 견적 통계 정보 */} + <TableRow> + <TableCell className="font-medium">예상액</TableCell> + <TableCell className="font-semibold"> + {quotationDetails?.estimatedPrice ? formatCurrency(quotationDetails.estimatedPrice) : '-'} + </TableCell> + </TableRow> + <TableRow> + <TableCell className="font-medium">최저견적가</TableCell> + <TableCell className="font-semibold text-green-600"> + {quotationDetails?.lowestQuote ? formatCurrency(quotationDetails.lowestQuote) : '-'} + </TableCell> + </TableRow> + <TableRow> + <TableCell className="font-medium">평균견적가</TableCell> + <TableCell className="font-semibold"> + {quotationDetails?.averageQuote ? formatCurrency(quotationDetails.averageQuote) : '-'} + </TableCell> + </TableRow> + <TableRow> + <TableCell className="font-medium">견적 수</TableCell> + <TableCell className="font-semibold"> + {quotationDetails?.quotationCount || 0}개 + </TableCell> + </TableRow> + + {/* 예산 정보 */} + {bidding.budget && ( + <TableRow> + <TableCell className="font-medium">예산</TableCell> + <TableCell className="font-semibold"> + {formatCurrency(Number(bidding.budget))} + </TableCell> + </TableRow> + )} + + {/* 최종 업데이트 시간 */} + {quotationDetails?.lastUpdated && ( + <TableRow> + <TableCell className="font-medium">최종 업데이트</TableCell> + <TableCell className="text-sm text-muted-foreground"> + {new Date(quotationDetails.lastUpdated).toLocaleString('ko-KR')} + </TableCell> + </TableRow> + )} + + {/* 내정가 입력 */} + <TableRow> + <TableCell className="font-medium"> + <Label htmlFor="targetPrice" className="text-sm font-medium"> + 내정가 * + </Label> + </TableCell> + <TableCell> + <div className="space-y-2"> + <Input + id="targetPrice" + type="number" + value={targetPrice} + onChange={(e) => setTargetPrice(Number(e.target.value))} + placeholder="내정가를 입력하세요" + className="w-full" + /> + <div className="text-sm text-muted-foreground"> + {targetPrice > 0 ? formatCurrency(targetPrice) : ''} + </div> + </div> + </TableCell> + </TableRow> + + {/* 내정가 산정 기준 입력 */} + <TableRow> + <TableCell className="font-medium align-top pt-2"> + <Label htmlFor="calculationCriteria" className="text-sm font-medium"> + 내정가 산정 기준 * + </Label> + </TableCell> + <TableCell> + <Textarea + id="calculationCriteria" + value={calculationCriteria} + onChange={(e) => setCalculationCriteria(e.target.value)} + placeholder="내정가 산정 기준을 자세히 입력해주세요. (예: 최저견적가 대비 10% 상향 조정, 시장 평균가 고려 등)" + className="w-full min-h-[100px]" + rows={4} + /> + <div className="text-xs text-muted-foreground mt-1"> + 필수 입력 사항입니다. 내정가 산정에 대한 근거를 명확히 기재해주세요. + </div> + </TableCell> + </TableRow> + </TableBody> + </Table> + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleSave} disabled={isPending}> + 저장 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx new file mode 100644 index 00000000..ef075459 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -0,0 +1,223 @@ +"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, Trophy +} from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { QuotationVendor } from "@/lib/bidding/detail/service" + +interface GetVendorColumnsProps { + onEdit: (vendor: QuotationVendor) => void + onDelete: (vendor: QuotationVendor) => void + onSelectWinner: (vendor: QuotationVendor) => void +} + +export function getBiddingDetailVendorColumns({ + onEdit, + onDelete, + onSelectWinner +}: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] { + 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: 'vendorName', + header: '업체명', + cell: ({ row }) => ( + <div className="font-medium">{row.original.vendorName}</div> + ), + }, + { + accessorKey: 'vendorCode', + header: '업체코드', + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.original.vendorCode}</div> + ), + }, + { + accessorKey: 'contactPerson', + header: '담당자', + cell: ({ row }) => ( + <div className="text-sm">{row.original.contactPerson || '-'}</div> + ), + }, + { + accessorKey: 'quotationAmount', + header: '견적금액', + cell: ({ row }) => ( + <div className="text-right font-mono"> + {row.original.quotationAmount ? Number(row.original.quotationAmount).toLocaleString() : '-'} {row.original.currency} + </div> + ), + }, + { + accessorKey: 'awardRatio', + header: '발주비율', + cell: ({ row }) => ( + <div className="text-right"> + {row.original.awardRatio ? `${row.original.awardRatio}%` : '-'} + </div> + ), + }, + { + accessorKey: 'status', + header: '상태', + cell: ({ row }) => { + const status = row.original.status + const variant = status === 'selected' ? 'default' : + status === 'submitted' ? 'secondary' : + status === 'rejected' ? 'destructive' : 'outline' + + const label = status === 'selected' ? '선정' : + status === 'submitted' ? '제출' : + status === 'rejected' ? '거절' : '대기' + + return <Badge variant={variant}>{label}</Badge> + }, + }, + { + accessorKey: 'submissionDate', + header: '제출일', + cell: ({ row }) => ( + <div className="text-sm"> + {row.original.submissionDate ? new Date(row.original.submissionDate).toLocaleDateString('ko-KR') : '-'} + </div> + ), + }, + { + accessorKey: 'offeredPaymentTerms', + header: '지급조건', + cell: ({ row }) => { + const terms = row.original.offeredPaymentTerms + if (!terms) return <div className="text-muted-foreground">-</div> + + try { + const parsed = JSON.parse(terms) + return ( + <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}> + {parsed.join(', ')} + </div> + ) + } catch { + return <div className="text-sm max-w-32 truncate">{terms}</div> + } + }, + }, + { + accessorKey: 'offeredTaxConditions', + header: '세금조건', + cell: ({ row }) => { + const conditions = row.original.offeredTaxConditions + if (!conditions) return <div className="text-muted-foreground">-</div> + + try { + const parsed = JSON.parse(conditions) + return ( + <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}> + {parsed.join(', ')} + </div> + ) + } catch { + return <div className="text-sm max-w-32 truncate">{conditions}</div> + } + }, + }, + { + accessorKey: 'offeredIncoterms', + header: '운송조건', + cell: ({ row }) => { + const terms = row.original.offeredIncoterms + if (!terms) return <div className="text-muted-foreground">-</div> + + try { + const parsed = JSON.parse(terms) + return ( + <div className="text-sm max-w-24 truncate" title={parsed.join(', ')}> + {parsed.join(', ')} + </div> + ) + } catch { + return <div className="text-sm max-w-24 truncate">{terms}</div> + } + }, + }, + { + accessorKey: 'offeredContractDeliveryDate', + header: '납품요청일', + cell: ({ row }) => ( + <div className="text-sm"> + {row.original.offeredContractDeliveryDate ? + new Date(row.original.offeredContractDeliveryDate).toLocaleDateString('ko-KR') : '-'} + </div> + ), + }, + { + id: 'actions', + header: '작업', + cell: ({ row }) => { + const vendor = 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"> + <DropdownMenuLabel>작업</DropdownMenuLabel> + <DropdownMenuItem onClick={() => onEdit(vendor)}> + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + {vendor.status !== 'selected' && ( + <DropdownMenuItem onClick={() => onSelectWinner(vendor)}> + <Trophy className="mr-2 h-4 w-4" /> + 낙찰 선정 + </DropdownMenuItem> + )} + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => onDelete(vendor)} + className="text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + }, + ] +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx new file mode 100644 index 00000000..9229b09c --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx @@ -0,0 +1,335 @@ +'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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { Check, ChevronsUpDown, Search } from 'lucide-react' +import { cn } from '@/lib/utils' +import { createQuotationVendor } from '@/lib/bidding/detail/service' +import { createQuotationVendorSchema } from '@/lib/bidding/validation' +import { searchVendors } from '@/lib/vendors/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailVendorCreateDialogProps { + biddingId: number + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +interface Vendor { + id: number + vendorName: string + vendorCode: string + status: string +} + +export function BiddingDetailVendorCreateDialog({ + biddingId, + open, + onOpenChange, + onSuccess +}: BiddingDetailVendorCreateDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // Vendor 검색 상태 + const [vendors, setVendors] = React.useState<Vendor[]>([]) + const [selectedVendor, setSelectedVendor] = React.useState<Vendor | null>(null) + const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) + const [vendorSearchValue, setVendorSearchValue] = React.useState('') + + // 폼 상태 + const [formData, setFormData] = React.useState({ + quotationAmount: 0, + currency: 'KRW', + paymentTerms: '', + taxConditions: '', + deliveryDate: '', + awardRatio: 0, + status: 'pending' as const, + }) + + // Vendor 검색 + React.useEffect(() => { + const search = async () => { + if (vendorSearchValue.trim().length < 2) { + setVendors([]) + return + } + + try { + const result = await searchVendors(vendorSearchValue.trim(), 10) + setVendors(result) + } catch (error) { + console.error('Vendor search failed:', error) + setVendors([]) + } + } + + const debounceTimer = setTimeout(search, 300) + return () => clearTimeout(debounceTimer) + }, [vendorSearchValue]) + + const handleVendorSelect = (vendor: Vendor) => { + setSelectedVendor(vendor) + setVendorSearchValue(`${vendor.vendorName} (${vendor.vendorCode})`) + setVendorSearchOpen(false) + } + + const handleCreate = () => { + if (!selectedVendor) { + toast({ + title: '오류', + description: '업체를 선택해주세요.', + variant: 'destructive', + }) + return + } + + const result = createQuotationVendorSchema.safeParse({ + biddingId, + vendorId: selectedVendor.id, + vendorName: selectedVendor.vendorName, + vendorCode: selectedVendor.vendorCode, + contactPerson: '', + contactEmail: '', + contactPhone: '', + ...formData, + }) + + if (!result.success) { + toast({ + title: '유효성 오류', + description: result.error.issues[0]?.message || '입력값을 확인해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const response = await createQuotationVendor(result.data, 'current-user') + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onOpenChange(false) + resetForm() + onSuccess() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const resetForm = () => { + setSelectedVendor(null) + setVendorSearchValue('') + setFormData({ + quotationAmount: 0, + currency: 'KRW', + paymentTerms: '', + taxConditions: '', + deliveryDate: '', + awardRatio: 0, + status: 'pending', + }) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>협력업체 추가</DialogTitle> + <DialogDescription> + 검색해서 업체를 선택하고 견적 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + {/* Vendor 검색 */} + <div className="space-y-2"> + <Label htmlFor="vendor-search">업체 검색</Label> + <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorSearchOpen} + className="w-full justify-between" + > + {selectedVendor + ? `${selectedVendor.vendorName} (${selectedVendor.vendorCode})` + : "업체를 검색해서 선택하세요..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput + placeholder="업체명 또는 코드를 입력하세요..." + value={vendorSearchValue} + onValueChange={setVendorSearchValue} + /> + <CommandEmpty> + {vendorSearchValue.length < 2 + ? "최소 2자 이상 입력해주세요" + : "검색 결과가 없습니다"} + </CommandEmpty> + <CommandGroup className="max-h-64 overflow-auto"> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorName} ${vendor.vendorCode}`} + onSelect={() => handleVendorSelect(vendor)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedVendor?.id === vendor.id ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex flex-col"> + <span className="font-medium">{vendor.vendorName}</span> + <span className="text-sm text-muted-foreground">{vendor.vendorCode}</span> + </div> + </CommandItem> + ))} + </CommandGroup> + </Command> + </PopoverContent> + </Popover> + </div> + + {/* 견적 정보 입력 */} + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="quotationAmount">견적금액</Label> + <Input + id="quotationAmount" + type="number" + value={formData.quotationAmount} + onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })} + placeholder="견적금액을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="currency">통화</Label> + <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="KRW">KRW</SelectItem> + <SelectItem value="USD">USD</SelectItem> + <SelectItem value="EUR">EUR</SelectItem> + </SelectContent> + </Select> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="awardRatio">발주비율 (%)</Label> + <Input + id="awardRatio" + type="number" + min="0" + max="100" + value={formData.awardRatio} + onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} + placeholder="발주비율을 입력하세요" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="status">상태</Label> + <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="pending">대기</SelectItem> + <SelectItem value="submitted">제출</SelectItem> + <SelectItem value="selected">선정</SelectItem> + <SelectItem value="rejected">거절</SelectItem> + </SelectContent> + </Select> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="paymentTerms">지급조건</Label> + <Input + id="paymentTerms" + value={formData.paymentTerms} + onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })} + placeholder="지급조건을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="taxConditions">세금조건</Label> + <Input + id="taxConditions" + value={formData.taxConditions} + onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })} + placeholder="세금조건을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="deliveryDate">납품일</Label> + <Input + id="deliveryDate" + type="date" + value={formData.deliveryDate} + onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })} + /> + </div> + </div> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleCreate} disabled={isPending || !selectedVendor}> + 추가 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx new file mode 100644 index 00000000..a48aadd2 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx @@ -0,0 +1,260 @@ +'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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { updateQuotationVendor } from '@/lib/bidding/detail/service' +import { updateQuotationVendorSchema } from '@/lib/bidding/validation' +import { QuotationVendor } from '@/lib/bidding/detail/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailVendorEditDialogProps { + vendor: QuotationVendor | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function BiddingDetailVendorEditDialog({ + vendor, + open, + onOpenChange, + onSuccess +}: BiddingDetailVendorEditDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + + // 폼 상태 + const [formData, setFormData] = React.useState({ + vendorName: '', + vendorCode: '', + contactPerson: '', + contactEmail: '', + contactPhone: '', + quotationAmount: 0, + currency: 'KRW', + paymentTerms: '', + taxConditions: '', + deliveryDate: '', + awardRatio: 0, + status: 'pending' as const, + }) + + // vendor가 변경되면 폼 데이터 업데이트 + React.useEffect(() => { + if (vendor) { + setFormData({ + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + contactPerson: vendor.contactPerson || '', + contactEmail: vendor.contactEmail || '', + contactPhone: vendor.contactPhone || '', + quotationAmount: vendor.quotationAmount, + currency: vendor.currency, + paymentTerms: vendor.paymentTerms || '', + taxConditions: vendor.taxConditions || '', + deliveryDate: vendor.deliveryDate || '', + awardRatio: vendor.awardRatio || 0, + status: vendor.status, + }) + } + }, [vendor]) + + const handleEdit = () => { + if (!vendor) return + + const result = updateQuotationVendorSchema.safeParse({ + id: vendor.id, + ...formData, + }) + + if (!result.success) { + toast({ + title: '유효성 오류', + description: result.error.issues[0]?.message || '입력값을 확인해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const response = await updateQuotationVendor(vendor.id, result.data, 'current-user') + + 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> + 협력업체 정보를 수정해주세요. + </DialogDescription> + </DialogHeader> + <div className="grid gap-4 py-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="edit-vendorName">업체명</Label> + <Input + id="edit-vendorName" + value={formData.vendorName} + onChange={(e) => setFormData({ ...formData, vendorName: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-vendorCode">업체코드</Label> + <Input + id="edit-vendorCode" + value={formData.vendorCode} + onChange={(e) => setFormData({ ...formData, vendorCode: e.target.value })} + /> + </div> + </div> + <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-quotationAmount">견적금액</Label> + <Input + id="edit-quotationAmount" + type="number" + value={formData.quotationAmount} + onChange={(e) => setFormData({ ...formData, quotationAmount: Number(e.target.value) })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-currency">통화</Label> + <Select value={formData.currency} onValueChange={(value) => setFormData({ ...formData, currency: value })}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="KRW">KRW</SelectItem> + <SelectItem value="USD">USD</SelectItem> + <SelectItem value="EUR">EUR</SelectItem> + </SelectContent> + </Select> + </div> + </div> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="edit-awardRatio">발주비율 (%)</Label> + <Input + id="edit-awardRatio" + type="number" + min="0" + max="100" + value={formData.awardRatio} + onChange={(e) => setFormData({ ...formData, awardRatio: Number(e.target.value) })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-status">상태</Label> + <Select value={formData.status} onValueChange={(value: any) => setFormData({ ...formData, status: value })}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="pending">대기</SelectItem> + <SelectItem value="submitted">제출</SelectItem> + <SelectItem value="selected">선정</SelectItem> + <SelectItem value="rejected">거절</SelectItem> + </SelectContent> + </Select> + </div> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-paymentTerms">지급조건</Label> + <Input + id="edit-paymentTerms" + value={formData.paymentTerms} + onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-taxConditions">세금조건</Label> + <Input + id="edit-taxConditions" + value={formData.taxConditions} + onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })} + /> + </div> + <div className="space-y-2"> + <Label htmlFor="edit-deliveryDate">납품일</Label> + <Input + id="edit-deliveryDate" + type="date" + value={formData.deliveryDate} + onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })} + /> + </div> + </div> + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button onClick={handleEdit} disabled={isPending}> + 수정 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx new file mode 100644 index 00000000..7ad7056c --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -0,0 +1,225 @@ +'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 { BiddingDetailVendorToolbarActions } from './bidding-detail-vendor-toolbar-actions' +import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' +import { BiddingDetailVendorEditDialog } from './bidding-detail-vendor-edit-dialog' +import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' +import { QuotationVendor } from '@/lib/bidding/detail/service' +import { + deleteQuotationVendor, + selectWinner +} from '@/lib/bidding/detail/service' +import { selectWinnerSchema } from '@/lib/bidding/validation' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface BiddingDetailVendorTableContentProps { + biddingId: number + vendors: QuotationVendor[] + onRefresh: () => void + onOpenItemsDialog: () => void + onOpenTargetPriceDialog: () => void + onOpenSelectionReasonDialog: () => void + onEdit?: (vendor: QuotationVendor) => void + onDelete?: (vendor: QuotationVendor) => void + onSelectWinner?: (vendor: QuotationVendor) => void +} + +const filterFields: DataTableFilterField<QuotationVendor>[] = [ + { + id: 'vendorName', + label: '업체명', + placeholder: '업체명으로 검색...', + }, + { + id: 'vendorCode', + label: '업체코드', + placeholder: '업체코드로 검색...', + }, + { + id: 'contactPerson', + label: '담당자', + placeholder: '담당자로 검색...', + }, +] + +const advancedFilterFields: DataTableAdvancedFilterField<QuotationVendor>[] = [ + { + id: 'vendorName', + label: '업체명', + type: 'text', + }, + { + id: 'vendorCode', + label: '업체코드', + type: 'text', + }, + { + id: 'contactPerson', + label: '담당자', + type: 'text', + }, + { + id: 'quotationAmount', + label: '견적금액', + type: 'number', + }, + { + id: 'status', + label: '상태', + type: 'multi-select', + options: [ + { label: '제출완료', value: 'submitted' }, + { label: '선정완료', value: 'selected' }, + { label: '미제출', value: 'pending' }, + ], + }, +] + +export function BiddingDetailVendorTableContent({ + biddingId, + vendors, + onRefresh, + onOpenItemsDialog, + onOpenTargetPriceDialog, + onOpenSelectionReasonDialog, + onEdit, + onDelete, + onSelectWinner +}: BiddingDetailVendorTableContentProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null) + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + + const handleDelete = (vendor: QuotationVendor) => { + if (!confirm(`${vendor.vendorName} 업체를 삭제하시겠습니까?`)) return + + startTransition(async () => { + const response = await deleteQuotationVendor(vendor.id) + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onRefresh() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const handleSelectWinner = (vendor: QuotationVendor) => { + if (!vendor.awardRatio || vendor.awardRatio <= 0) { + toast({ + title: '오류', + description: '발주비율을 먼저 설정해주세요.', + variant: 'destructive', + }) + return + } + + if (!confirm(`${vendor.vendorName} 업체를 낙찰자로 선정하시겠습니까?`)) return + + startTransition(async () => { + const result = selectWinnerSchema.safeParse({ + biddingId, + vendorId: vendor.id, + awardRatio: vendor.awardRatio, + }) + + if (!result.success) { + toast({ + title: '유효성 오류', + description: result.error.issues[0]?.message || '입력값을 확인해주세요.', + variant: 'destructive', + }) + return + } + + const response = await selectWinner(biddingId, vendor.id, vendor.awardRatio, 'current-user') + + if (response.success) { + toast({ + title: '성공', + description: response.message, + }) + onRefresh() + } else { + toast({ + title: '오류', + description: response.error, + variant: 'destructive', + }) + } + }) + } + + const handleEdit = (vendor: QuotationVendor) => { + setSelectedVendor(vendor) + setIsEditDialogOpen(true) + } + + const columns = React.useMemo( + () => getBiddingDetailVendorColumns({ + onEdit: onEdit || handleEdit, + onDelete: onDelete || handleDelete, + onSelectWinner: onSelectWinner || handleSelectWinner + }), + [onEdit, onDelete, onSelectWinner, handleEdit, handleDelete, handleSelectWinner] + ) + + const { table } = useDataTable({ + data: vendors, + columns, + pageCount: 1, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'vendorName', 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} + > + <BiddingDetailVendorToolbarActions + table={table} + biddingId={biddingId} + onOpenItemsDialog={onOpenItemsDialog} + onOpenTargetPriceDialog={onOpenTargetPriceDialog} + onOpenSelectionReasonDialog={onOpenSelectionReasonDialog} + + onSuccess={onRefresh} + /> + </DataTableAdvancedToolbar> + </DataTable> + + <BiddingDetailVendorEditDialog + vendor={selectedVendor} + open={isEditDialogOpen} + onOpenChange={setIsEditDialogOpen} + onSuccess={onRefresh} + /> + </> + ) +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx new file mode 100644 index 00000000..00daa005 --- /dev/null +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -0,0 +1,79 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { Plus } from "lucide-react" +import { QuotationVendor } from "@/lib/bidding/detail/service" +import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" + +interface BiddingDetailVendorToolbarActionsProps { + table: Table<QuotationVendor> + biddingId: number + onOpenItemsDialog: () => void + onOpenTargetPriceDialog: () => void + onOpenSelectionReasonDialog: () => void + + onSuccess: () => void +} + +export function BiddingDetailVendorToolbarActions({ + table, + biddingId, + onOpenItemsDialog, + onOpenTargetPriceDialog, + onOpenSelectionReasonDialog, + onSuccess +}: BiddingDetailVendorToolbarActionsProps) { + const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) + + const handleCreateVendor = () => { + setIsCreateDialogOpen(true) + } + + return ( + <> + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={onOpenItemsDialog} + > + 품목 정보 + </Button> + <Button + variant="outline" + size="sm" + onClick={onOpenTargetPriceDialog} + > + 내정가 산정 + </Button> + <Button + variant="outline" + size="sm" + onClick={onOpenSelectionReasonDialog} + > + 선정 사유 + </Button> + <Button + variant="default" + size="sm" + onClick={handleCreateVendor} + > + <Plus className="mr-2 h-4 w-4" /> + 업체 추가 + </Button> + </div> + + <BiddingDetailVendorCreateDialog + biddingId={biddingId} + open={isCreateDialogOpen} + onOpenChange={setIsCreateDialogOpen} + onSuccess={() => { + onSuccess() + setIsCreateDialogOpen(false) + }} + /> + </> + ) +} |
