diff options
Diffstat (limited to 'lib/bidding')
18 files changed, 4358 insertions, 1 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) + }} + /> + </> + ) +} diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index fde77bfb..c936de33 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -554,7 +554,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef <Eye className="mr-2 h-4 w-4" /> 상세보기 </DropdownMenuItem> - <DropdownMenuItem onClick={() => setRowAction({ row, type: "edit" })}> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "update" })}> <Edit className="mr-2 h-4 w-4" /> 수정 </DropdownMenuItem> diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx index 672b756b..3b60c69b 100644 --- a/lib/bidding/list/biddings-table.tsx +++ b/lib/bidding/list/biddings-table.tsx @@ -57,6 +57,13 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { setSelectedBidding(rowAction.row.original) switch (rowAction.type) { + case "view": + // 상세 페이지로 이동 + router.push(`/evcp/bid/${rowAction.row.original.id}`) + break + case "update": + // EditBiddingSheet는 아래에서 별도로 처리 + break case "specification_meeting": setSpecMeetingDialogOpen(true) break diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts index 556395b5..5dec3ab3 100644 --- a/lib/bidding/validation.ts +++ b/lib/bidding/validation.ts @@ -155,3 +155,67 @@ export const createBiddingSchema = z.object({ export type GetBiddingsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> export type CreateBiddingSchema = z.infer<typeof createBiddingSchema> export type UpdateBiddingSchema = z.infer<typeof updateBiddingSchema> + + // === 상세 페이지용 검증 스키마들 === + + // 내정가 업데이트 스키마 + export const updateTargetPriceSchema = z.object({ + biddingId: z.number().int().positive('입찰 ID는 필수입니다'), + targetPrice: z.number().min(0, '내정가는 0 이상이어야 합니다'), + }) + + // 협력업체 정보 생성 스키마 + export const createQuotationVendorSchema = z.object({ + biddingId: z.number().int().positive('입찰 ID는 필수입니다'), + vendorId: z.number().int().positive('업체 ID는 필수입니다'), + vendorName: z.string().min(1, '업체명은 필수입니다'), + vendorCode: z.string().min(1, '업체코드는 필수입니다'), + contactPerson: z.string().optional(), + contactEmail: z.string().email().optional().or(z.literal('')), + contactPhone: z.string().optional(), + quotationAmount: z.number().min(0, '견적금액은 0 이상이어야 합니다'), + currency: z.string().min(1, '통화는 필수입니다').default('KRW'), + paymentTerms: z.string().optional(), + taxConditions: z.string().optional(), + deliveryDate: z.string().optional(), + awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다').optional(), + status: z.enum(['pending', 'submitted', 'selected', 'rejected']).default('pending'), + }) + + // 협력업체 정보 업데이트 스키마 + export const updateQuotationVendorSchema = z.object({ + id: z.number().int().positive('협력업체 ID는 필수입니다'), + vendorName: z.string().min(1, '업체명은 필수입니다').optional(), + vendorCode: z.string().min(1, '업체코드는 필수입니다').optional(), + contactPerson: z.string().optional(), + contactEmail: z.string().email().optional().or(z.literal('')), + contactPhone: z.string().optional(), + quotationAmount: z.number().min(0, '견적금액은 0 이상이어야 합니다').optional(), + currency: z.string().min(1, '통화는 필수입니다').optional(), + paymentTerms: z.string().optional(), + taxConditions: z.string().optional(), + deliveryDate: z.string().optional(), + awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다').optional(), + status: z.enum(['pending', 'submitted', 'selected', 'rejected']).optional(), + }) + + // 낙찰 선택 스키마 + export const selectWinnerSchema = z.object({ + biddingId: z.number().int().positive('입찰 ID는 필수입니다'), + vendorId: z.number().int().positive('업체 ID는 필수입니다'), + awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다'), + }) + + // 입찰 상태 변경 스키마 + export const updateBiddingStatusSchema = z.object({ + biddingId: z.number().int().positive('입찰 ID는 필수입니다'), + status: z.enum(biddings.status.enumValues, { + required_error: '입찰 상태는 필수입니다' + }), + }) + + export type UpdateTargetPriceSchema = z.infer<typeof updateTargetPriceSchema> + export type CreateQuotationVendorSchema = z.infer<typeof createQuotationVendorSchema> + export type UpdateQuotationVendorSchema = z.infer<typeof updateQuotationVendorSchema> + export type SelectWinnerSchema = z.infer<typeof selectWinnerSchema> + export type UpdateBiddingStatusSchema = z.infer<typeof updateBiddingStatusSchema> diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx new file mode 100644 index 00000000..270d9ccd --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx @@ -0,0 +1,252 @@ +'use client' + +import * as React from 'react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Label } from '@/components/ui/label' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { Textarea } from '@/components/ui/textarea' +import { Badge } from '@/components/ui/badge' +import { + Calendar, + Users, + MapPin, + Clock, + FileText, + CheckCircle, + XCircle +} from 'lucide-react' + +import { formatDate } from '@/lib/utils' +import { updatePartnerAttendance } from '../detail/service' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface PartnersBiddingAttendanceDialogProps { + biddingDetail: { + id: number + biddingNumber: string + title: string + preQuoteDate: string | null + biddingRegistrationDate: string | null + evaluationDate: string | null + } | null + biddingCompanyId: number + isAttending: boolean | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function PartnersBiddingAttendanceDialog({ + biddingDetail, + biddingCompanyId, + isAttending, + open, + onOpenChange, + onSuccess, +}: PartnersBiddingAttendanceDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [attendance, setAttendance] = React.useState<string>('') + const [comments, setComments] = React.useState<string>('') + + // 다이얼로그 열릴 때 기존 값으로 초기화 + React.useEffect(() => { + if (open) { + if (isAttending === true) { + setAttendance('attending') + } else if (isAttending === false) { + setAttendance('not_attending') + } else { + setAttendance('') + } + setComments('') + } + }, [open, isAttending]) + + const handleSubmit = () => { + if (!attendance) { + toast({ + title: '선택 필요', + description: '참석 여부를 선택해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await updatePartnerAttendance( + biddingCompanyId, + attendance === 'attending', + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + onSuccess() + onOpenChange(false) + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + if (!biddingDetail) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Users className="w-5 h-5" /> + 사양설명회 참석 여부 + </DialogTitle> + <DialogDescription> + 입찰에 대한 사양설명회 참석 여부를 선택해주세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-6"> + {/* 입찰 정보 요약 */} + <div className="bg-muted p-4 rounded-lg space-y-3"> + <div className="flex items-center gap-2"> + <FileText className="w-4 h-4 text-muted-foreground" /> + <span className="font-medium">{biddingDetail.title}</span> + </div> + <div className="flex items-center gap-2"> + <Badge variant="outline" className="font-mono"> + {biddingDetail.biddingNumber} + </Badge> + </div> + + {/* 주요 일정 */} + <div className="grid grid-cols-1 gap-2 text-sm"> + {biddingDetail.preQuoteDate && ( + <div className="flex items-center gap-2"> + <Calendar className="w-4 h-4 text-muted-foreground" /> + <span>사전견적 마감: {formatDate(biddingDetail.preQuoteDate, 'KR')}</span> + </div> + )} + {biddingDetail.biddingRegistrationDate && ( + <div className="flex items-center gap-2"> + <Calendar className="w-4 h-4 text-muted-foreground" /> + <span>입찰등록 마감: {formatDate(biddingDetail.biddingRegistrationDate, 'KR')}</span> + </div> + )} + {biddingDetail.evaluationDate && ( + <div className="flex items-center gap-2"> + <Calendar className="w-4 h-4 text-muted-foreground" /> + <span>평가일: {formatDate(biddingDetail.evaluationDate, 'KR')}</span> + </div> + )} + </div> + </div> + + {/* 참석 여부 선택 */} + <div className="space-y-3"> + <Label className="text-base font-medium">참석 여부를 선택해주세요</Label> + <RadioGroup + value={attendance} + onValueChange={setAttendance} + className="space-y-3" + > + <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors"> + <RadioGroupItem value="attending" id="attending" /> + <div className="flex items-center gap-2 flex-1"> + <CheckCircle className="w-5 h-5 text-green-600" /> + <Label htmlFor="attending" className="font-medium cursor-pointer"> + 참석합니다 + </Label> + </div> + </div> + <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors"> + <RadioGroupItem value="not_attending" id="not_attending" /> + <div className="flex items-center gap-2 flex-1"> + <XCircle className="w-5 h-5 text-red-600" /> + <Label htmlFor="not_attending" className="font-medium cursor-pointer"> + 참석하지 않습니다 + </Label> + </div> + </div> + </RadioGroup> + </div> + + {/* 참석하지 않는 경우 의견 */} + {attendance === 'not_attending' && ( + <div className="space-y-2"> + <Label htmlFor="comments">불참 사유 (선택사항)</Label> + <Textarea + id="comments" + value={comments} + onChange={(e) => setComments(e.target.value)} + placeholder="참석하지 않는 이유를 간단히 설명해주세요." + rows={3} + className="resize-none" + /> + </div> + )} + + {/* 참석하는 경우 추가 정보 */} + {attendance === 'attending' && ( + <div className="bg-green-50 border border-green-200 rounded-lg p-4"> + <div className="flex items-start gap-2"> + <CheckCircle className="w-5 h-5 text-green-600 mt-0.5" /> + <div className="space-y-1"> + <p className="font-medium text-green-800">참석 확인</p> + <p className="text-sm text-green-700"> + 사양설명회에 참석하겠다고 응답하셨습니다. + 회의 일정 및 장소는 추후 별도 안내드리겠습니다. + </p> + </div> + </div> + </div> + )} + + {/* 현재 상태 표시 */} + {isAttending !== null && ( + <div className="bg-blue-50 border border-blue-200 rounded-lg p-3"> + <div className="flex items-center gap-2 text-blue-800"> + <Clock className="w-4 h-4" /> + <span className="text-sm"> + 현재 상태: {isAttending ? '참석' : '불참'} ({formatDate(new Date().toISOString(), 'KR')} 기준) + </span> + </div> + </div> + )} + </div> + + <DialogFooter className="flex gap-2"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isPending || !attendance} + className="min-w-[100px]" + > + {isPending ? '저장 중...' : '저장'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx new file mode 100644 index 00000000..4c4db37f --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -0,0 +1,562 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +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 { + ArrowLeft, + Calendar, + Building2, + Package, + User, + DollarSign, + FileText, + Users, + Send, + CheckCircle, + XCircle +} from 'lucide-react' + +import { formatDate } from '@/lib/utils' +import { + getBiddingDetailsForPartners, + submitPartnerResponse, + updatePartnerAttendance +} from '../detail/service' +import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog' +import { + biddingStatusLabels, + contractTypeLabels, + biddingTypeLabels +} from '@/db/schema' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' + +interface PartnersBiddingDetailProps { + biddingId: number + companyId: number +} + +interface BiddingDetail { + id: number + biddingNumber: string + revision: number + projectName: string + itemName: string + title: string + description: string + content: string + contractType: string + biddingType: string + awardCount: string + contractPeriod: string + preQuoteDate: string + biddingRegistrationDate: string + submissionStartDate: string + submissionEndDate: string + evaluationDate: string + currency: string + budget: number + targetPrice: number + status: string + managerName: string + managerEmail: string + managerPhone: string + biddingCompanyId: number + biddingId: number // bidding의 ID 추가 + invitationStatus: string + finalQuoteAmount: number + finalQuoteSubmittedAt: string + isWinner: boolean + isAttendingMeeting: boolean | null + offeredPaymentTerms: string + offeredTaxConditions: string + offeredIncoterms: string + offeredContractDeliveryDate: string + offeredShippingPort: string + offeredDestinationPort: string + isPriceAdjustmentApplicable: boolean + responsePaymentTerms: string + responseTaxConditions: string + responseIncoterms: string + proposedContractDeliveryDate: string + proposedShippingPort: string + proposedDestinationPort: string + priceAdjustmentResponse: boolean + additionalProposals: string + responseSubmittedAt: string +} + +export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingDetailProps) { + const router = useRouter() + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null) + const [isLoading, setIsLoading] = React.useState(true) + + // 응찰 폼 상태 + const [responseData, setResponseData] = React.useState({ + finalQuoteAmount: '', + paymentTermsResponse: '', + taxConditionsResponse: '', + incotermsResponse: '', + proposedContractDeliveryDate: '', + proposedShippingPort: '', + proposedDestinationPort: '', + priceAdjustmentResponse: false, + additionalProposals: '', + isAttendingMeeting: false, + }) + + // 사양설명회 참석 여부 다이얼로그 상태 + const [isAttendanceDialogOpen, setIsAttendanceDialogOpen] = React.useState(false) + + // 데이터 로드 + React.useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true) + const result = await getBiddingDetailsForPartners(biddingId, companyId) + if (result) { + setBiddingDetail(result) + + // 기존 응답 데이터로 폼 초기화 + setResponseData({ + finalQuoteAmount: result.finalQuoteAmount?.toString() || '', + paymentTermsResponse: result.responsePaymentTerms || '', + taxConditionsResponse: result.responseTaxConditions || '', + incotermsResponse: result.responseIncoterms || '', + proposedContractDeliveryDate: result.proposedContractDeliveryDate || '', + proposedShippingPort: result.proposedShippingPort || '', + proposedDestinationPort: result.proposedDestinationPort || '', + priceAdjustmentResponse: result.priceAdjustmentResponse || false, + additionalProposals: result.additionalProposals || '', + isAttendingMeeting: false, // TODO: biddingCompanies에서 가져와야 함 + }) + } + } catch (error) { + console.error('Failed to load bidding detail:', error) + toast({ + title: '오류', + description: '입찰 정보를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + } + + loadData() + }, [biddingId, companyId, toast]) + + const handleSubmitResponse = () => { + if (!biddingDetail) return + + // 필수값 검증 + if (!responseData.finalQuoteAmount.trim()) { + toast({ + title: '유효성 오류', + description: '견적 금액을 입력해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await submitPartnerResponse( + biddingDetail.biddingCompanyId, + { + finalQuoteAmount: parseFloat(responseData.finalQuoteAmount), + paymentTermsResponse: responseData.paymentTermsResponse, + taxConditionsResponse: responseData.taxConditionsResponse, + incotermsResponse: responseData.incotermsResponse, + proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, + proposedShippingPort: responseData.proposedShippingPort, + proposedDestinationPort: responseData.proposedDestinationPort, + priceAdjustmentResponse: responseData.priceAdjustmentResponse, + additionalProposals: responseData.additionalProposals, + }, + 'current-user' // TODO: 실제 사용자 ID + ) + + if (result.success) { + toast({ + title: '성공', + description: result.message, + }) + + // 사양설명회 참석 여부도 업데이트 + if (responseData.isAttendingMeeting !== undefined) { + await updatePartnerAttendance( + biddingDetail.biddingCompanyId, + responseData.isAttendingMeeting, + 'current-user' + ) + } + + // 데이터 새로고침 + const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId) + if (updatedDetail) { + setBiddingDetail(updatedDetail) + } + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: biddingDetail?.currency || 'KRW', + }).format(amount) + } + + if (isLoading) { + return ( + <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> + ) + } + + if (!biddingDetail) { + return ( + <div className="text-center py-12"> + <p className="text-muted-foreground">입찰 정보를 찾을 수 없습니다.</p> + <Button onClick={() => router.back()} className="mt-4"> + <ArrowLeft className="w-4 h-4 mr-2" /> + 돌아가기 + </Button> + </div> + ) + } + + return ( + <div className="space-y-6"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Button variant="outline" onClick={() => router.back()}> + <ArrowLeft className="w-4 h-4 mr-2" /> + 목록으로 + </Button> + <div> + <h1 className="text-2xl font-semibold">{biddingDetail.title}</h1> + <div className="flex items-center gap-2 mt-1"> + <Badge variant="outline" className="font-mono"> + {biddingDetail.biddingNumber} + {biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`} + </Badge> + <Badge variant={ + biddingDetail.status === 'bidding_disposal' ? 'destructive' : + biddingDetail.status === 'vendor_selected' ? 'default' : + 'secondary' + }> + {biddingStatusLabels[biddingDetail.status]} + </Badge> + </div> + </div> + </div> + + {/* 사양설명회 참석 여부 버튼 */} + <div className="flex items-center gap-2"> + <Button + variant="outline" + onClick={() => setIsAttendanceDialogOpen(true)} + className="flex items-center gap-2" + > + <Users className="w-4 h-4" /> + 사양설명회 참석 + {biddingDetail.isAttendingMeeting !== null && ( + <div className="ml-1"> + {biddingDetail.isAttendingMeeting ? ( + <CheckCircle className="w-4 h-4 text-green-600" /> + ) : ( + <XCircle className="w-4 h-4 text-red-600" /> + )} + </div> + )} + </Button> + </div> + </div> + + {/* 입찰 공고 섹션 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 입찰 공고 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div> + <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label> + <div className="flex items-center gap-2 mt-1"> + <Building2 className="w-4 h-4" /> + <span>{biddingDetail.projectName}</span> + </div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">품목</Label> + <div className="flex items-center gap-2 mt-1"> + <Package className="w-4 h-4" /> + <span>{biddingDetail.itemName}</span> + </div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">계약구분</Label> + <div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">입찰유형</Label> + <div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label> + <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div> + </div> + <div> + <Label className="text-sm font-medium text-muted-foreground">담당자</Label> + <div className="flex items-center gap-2 mt-1"> + <User className="w-4 h-4" /> + <span>{biddingDetail.managerName}</span> + </div> + </div> + </div> + + {biddingDetail.budget && ( + <div> + <Label className="text-sm font-medium text-muted-foreground">예산</Label> + <div className="flex items-center gap-2 mt-1"> + <DollarSign className="w-4 h-4" /> + <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span> + </div> + </div> + )} + + {/* 일정 정보 */} + <div className="pt-4 border-t"> + <Label className="text-sm font-medium text-muted-foreground mb-2 block">일정 정보</Label> + <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm"> + {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && ( + <div> + <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')} + </div> + )} + {biddingDetail.evaluationDate && ( + <div> + <span className="font-medium">평가일:</span> {formatDate(biddingDetail.evaluationDate, 'KR')} + </div> + )} + </div> + </div> + </CardContent> + </Card> + + {/* 제시된 조건 섹션 */} + <Card> + <CardHeader> + <CardTitle>제시된 입찰 조건</CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-4"> + <div> + <Label className="text-sm font-medium">지급조건</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + {biddingDetail.offeredPaymentTerms ? + JSON.parse(biddingDetail.offeredPaymentTerms).join(', ') : + '정보 없음'} + </div> + </div> + + <div> + <Label className="text-sm font-medium">세금조건</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + {biddingDetail.offeredTaxConditions ? + JSON.parse(biddingDetail.offeredTaxConditions).join(', ') : + '정보 없음'} + </div> + </div> + + <div> + <Label className="text-sm font-medium">운송조건</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + {biddingDetail.offeredIncoterms ? + JSON.parse(biddingDetail.offeredIncoterms).join(', ') : + '정보 없음'} + </div> + </div> + + {biddingDetail.offeredContractDeliveryDate && ( + <div> + <Label className="text-sm font-medium">계약납기일</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + {formatDate(biddingDetail.offeredContractDeliveryDate, 'KR')} + </div> + </div> + )} + </div> + </CardContent> + </Card> + + {/* 응찰 폼 섹션 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Send className="w-5 h-5" /> + 응찰하기 + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="finalQuoteAmount">견적금액 *</Label> + <Input + id="finalQuoteAmount" + type="number" + value={responseData.finalQuoteAmount} + onChange={(e) => setResponseData({...responseData, finalQuoteAmount: e.target.value})} + placeholder="견적금액을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="proposedContractDeliveryDate">제안 납품일</Label> + <Input + id="proposedContractDeliveryDate" + type="date" + value={responseData.proposedContractDeliveryDate} + onChange={(e) => setResponseData({...responseData, proposedContractDeliveryDate: e.target.value})} + /> + </div> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="paymentTermsResponse">응답 지급조건</Label> + <Input + id="paymentTermsResponse" + value={responseData.paymentTermsResponse} + onChange={(e) => setResponseData({...responseData, paymentTermsResponse: e.target.value})} + placeholder="지급조건에 대한 의견을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="taxConditionsResponse">응답 세금조건</Label> + <Input + id="taxConditionsResponse" + value={responseData.taxConditionsResponse} + onChange={(e) => setResponseData({...responseData, taxConditionsResponse: e.target.value})} + placeholder="세금조건에 대한 의견을 입력하세요" + /> + </div> + </div> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-2"> + <Label htmlFor="incotermsResponse">응답 운송조건</Label> + <Input + id="incotermsResponse" + value={responseData.incotermsResponse} + onChange={(e) => setResponseData({...responseData, incotermsResponse: e.target.value})} + placeholder="운송조건에 대한 의견을 입력하세요" + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="proposedShippingPort">제안 선적지</Label> + <Input + id="proposedShippingPort" + value={responseData.proposedShippingPort} + onChange={(e) => setResponseData({...responseData, proposedShippingPort: e.target.value})} + placeholder="선적지를 입력하세요" + /> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="additionalProposals">추가 제안사항</Label> + <Textarea + id="additionalProposals" + value={responseData.additionalProposals} + onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})} + placeholder="추가 제안사항을 입력하세요" + rows={4} + /> + </div> + + <div className="flex items-center space-x-2"> + <Checkbox + id="priceAdjustmentResponse" + checked={responseData.priceAdjustmentResponse} + onCheckedChange={(checked) => + setResponseData({...responseData, priceAdjustmentResponse: !!checked}) + } + /> + <Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label> + </div> + + <div className="flex items-center space-x-2"> + <Checkbox + id="isAttendingMeeting" + checked={responseData.isAttendingMeeting} + onCheckedChange={(checked) => + setResponseData({...responseData, isAttendingMeeting: !!checked}) + } + /> + <Label htmlFor="isAttendingMeeting">사양설명회에 참석합니다</Label> + </div> + + <div className="flex justify-end pt-4"> + <Button onClick={handleSubmitResponse} disabled={isPending}> + <Send className="w-4 h-4 mr-2" /> + 응찰 제출 + </Button> + </div> + </CardContent> + </Card> + + {/* 사양설명회 참석 여부 다이얼로그 */} + <PartnersBiddingAttendanceDialog + biddingDetail={{ + id: biddingDetail.id, + biddingNumber: biddingDetail.biddingNumber, + title: biddingDetail.title, + preQuoteDate: biddingDetail.preQuoteDate, + biddingRegistrationDate: biddingDetail.biddingRegistrationDate, + evaluationDate: biddingDetail.evaluationDate, + }} + biddingCompanyId={biddingDetail.biddingCompanyId} + isAttending={biddingDetail.isAttendingMeeting} + open={isAttendanceDialogOpen} + onOpenChange={setIsAttendanceDialogOpen} + onSuccess={() => { + // 데이터 새로고침 + const refreshData = async () => { + const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId) + if (updatedDetail) { + setBiddingDetail(updatedDetail) + } + } + refreshData() + }} + /> + </div> + ) +} diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx new file mode 100644 index 00000000..b54ca967 --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -0,0 +1,260 @@ +'use client' + +import * as React from 'react' +import { createColumnHelper } from '@tanstack/react-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + CheckCircle, + XCircle, + Users, + Eye, + MoreHorizontal, + Calendar, + User +} from 'lucide-react' +import { formatDate } from '@/lib/utils' +import { biddingStatusLabels, contractTypeLabels } from '@/db/schema' +import { PartnersBiddingListItem } from '../detail/service' + +const columnHelper = createColumnHelper<PartnersBiddingListItem>() + +interface PartnersBiddingListColumnsProps { + setRowAction?: (action: { type: string; row: { original: PartnersBiddingListItem } }) => void +} + +export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingListColumnsProps = {}) { + return [ + // 입찰 No. + columnHelper.accessor('biddingNumber', { + header: '입찰 No.', + cell: ({ row }) => { + const biddingNumber = row.original.biddingNumber + const revision = row.original.revision + return ( + <div className="font-mono text-sm"> + <div>{biddingNumber}</div> + {revision > 0 && ( + <div className="text-muted-foreground">Rev.{revision}</div> + )} + </div> + ) + }, + }), + + // 입찰상태 + columnHelper.accessor('status', { + header: '입찰상태', + cell: ({ row }) => { + const status = row.original.status + return ( + <Badge variant={ + status === 'bidding_disposal' ? 'destructive' : + status === 'vendor_selected' ? 'default' : + status === 'bidding_generated' ? 'secondary' : + 'outline' + }> + {biddingStatusLabels[status] || status} + </Badge> + ) + }, + }), + + // 상세 (액션 버튼) + columnHelper.display({ + id: 'actions', + header: '상세', + cell: ({ row }) => { + const handleView = () => { + if (setRowAction) { + setRowAction({ + type: 'view', + row: { original: row.original } + }) + } + } + + return ( + <Button + variant="outline" + size="sm" + onClick={handleView} + className="h-8 w-8 p-0" + > + <Eye className="h-4 w-4" /> + </Button> + ) + }, + }), + + // 품목명 + columnHelper.accessor('itemName', { + header: '품목명', + cell: ({ row }) => ( + <div className="max-w-32 truncate" title={row.original.itemName}> + {row.original.itemName} + </div> + ), + }), + + // 입찰명 + columnHelper.accessor('title', { + header: '입찰명', + cell: ({ row }) => ( + <div className="max-w-48 truncate" title={row.original.title}> + {row.original.title} + </div> + ), + }), + + // 사양설명회 + columnHelper.accessor('isAttendingMeeting', { + header: '사양설명회', + cell: ({ row }) => { + const isAttending = row.original.isAttendingMeeting + if (isAttending === null) { + return <div className="text-muted-foreground text-center">-</div> + } + return isAttending ? ( + <CheckCircle className="h-5 w-5 text-green-600 mx-auto" /> + ) : ( + <XCircle className="h-5 w-5 text-red-600 mx-auto" /> + ) + }, + }), + + // 입찰 참여의사 + columnHelper.accessor('invitationStatus', { + header: '입찰 참여의사', + cell: ({ row }) => { + const status = row.original.invitationStatus + const statusLabels = { + sent: '초대됨', + submitted: '참여', + declined: '불참', + pending: '대기중' + } + return ( + <Badge variant={ + status === 'submitted' ? 'default' : + status === 'declined' ? 'destructive' : + status === 'sent' ? 'secondary' : + 'outline' + }> + {statusLabels[status as keyof typeof statusLabels] || status} + </Badge> + ) + }, + }), + + // 계약구분 + columnHelper.accessor('contractType', { + header: '계약구분', + cell: ({ row }) => ( + <div>{contractTypeLabels[row.original.contractType] || row.original.contractType}</div> + ), + }), + + // 입찰기간 + columnHelper.accessor('submissionStartDate', { + header: '입찰기간', + cell: ({ row }) => { + const startDate = row.original.submissionStartDate + const endDate = row.original.submissionEndDate + if (!startDate || !endDate) { + return <div className="text-muted-foreground">-</div> + } + return ( + <div className="text-sm"> + <div>{formatDate(startDate, 'KR')}</div> + <div className="text-muted-foreground">~</div> + <div>{formatDate(endDate, 'KR')}</div> + </div> + ) + }, + }), + + // 계약기간 + columnHelper.accessor('contractPeriod', { + header: '계약기간', + cell: ({ row }) => ( + <div className="max-w-24 truncate" title={row.original.contractPeriod || ''}> + {row.original.contractPeriod || '-'} + </div> + ), + }), + + // 참여회신 마감일 + columnHelper.accessor('responseDeadline', { + header: '참여회신 마감일', + cell: ({ row }) => { + const deadline = row.original.responseDeadline + if (!deadline) { + return <div className="text-muted-foreground">-</div> + } + return <div className="text-sm">{formatDate(deadline, 'KR')}</div> + }, + }), + + // 입찰제출일 + columnHelper.accessor('submissionDate', { + header: '입찰제출일', + cell: ({ row }) => { + const date = row.original.submissionDate + if (!date) { + return <div className="text-muted-foreground">-</div> + } + return <div className="text-sm">{formatDate(date, 'KR')}</div> + }, + }), + + // 입찰담당자 + columnHelper.accessor('managerName', { + header: '입찰담당자', + cell: ({ row }) => { + const name = row.original.managerName + const email = row.original.managerEmail + if (!name) { + return <div className="text-muted-foreground">-</div> + } + return ( + <div className="flex items-center gap-1"> + <User className="h-4 w-4" /> + <div> + <div className="text-sm">{name}</div> + {email && ( + <div className="text-xs text-muted-foreground truncate max-w-32" title={email}> + {email} + </div> + )} + </div> + </div> + ) + }, + }), + + // 최종수정일 + columnHelper.accessor('updatedAt', { + header: '최종수정일', + cell: ({ row }) => ( + <div className="text-sm">{formatDate(row.original.updatedAt, 'KR')}</div> + ), + }), + + // 최종수정자 + columnHelper.accessor('updatedBy', { + header: '최종수정자', + cell: ({ row }) => ( + <div className="max-w-20 truncate" title={row.original.updatedBy || ''}> + {row.original.updatedBy || '-'} + </div> + ), + }), + ] +} diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx new file mode 100644 index 00000000..c0356e22 --- /dev/null +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -0,0 +1,156 @@ +'use client' + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from '@/types/table' + +import { useDataTable } from '@/hooks/use-data-table' +import { DataTable } from '@/components/data-table/data-table' +import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar' +import { getPartnersBiddingListColumns } from './partners-bidding-list-columns' +import { getBiddingListForPartners, PartnersBiddingListItem } from '../detail/service' + +interface PartnersBiddingListProps { + companyId: number +} + +export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { + const [data, setData] = React.useState<PartnersBiddingListItem[]>([]) + const [pageCount, setPageCount] = React.useState<number>(1) + const [isLoading, setIsLoading] = React.useState(true) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PartnersBiddingListItem> | null>(null) + + const router = useRouter() + + // 데이터 로드 + React.useEffect(() => { + const loadData = async () => { + try { + setIsLoading(true) + const result = await getBiddingListForPartners(companyId) + setData(result) + setPageCount(1) // 클라이언트 사이드 페이징이므로 1로 설정 + } catch (error) { + console.error('Failed to load bidding list:', error) + setData([]) + } finally { + setIsLoading(false) + } + } + + loadData() + }, [companyId]) + + // rowAction 변경 감지하여 해당 페이지로 이동 + React.useEffect(() => { + if (rowAction) { + switch (rowAction.type) { + case 'view': + // 상세 페이지로 이동 (biddingId 사용) + router.push(`/partners/bid/${rowAction.row.original.biddingId}`) + break + default: + break + } + } + }, [rowAction, router]) + + const columns = React.useMemo( + () => getPartnersBiddingListColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField<PartnersBiddingListItem>[] = [ + { + id: 'title', + label: '입찰명', + placeholder: '입찰명으로 검색...', + }, + { + id: 'biddingNumber', + label: '입찰번호', + placeholder: '입찰번호로 검색...', + }, + { + id: 'itemName', + label: '품목명', + placeholder: '품목명으로 검색...', + }, + { + id: 'projectName', + label: '프로젝트명', + placeholder: '프로젝트명으로 검색...', + }, + { + id: 'managerName', + label: '담당자', + placeholder: '담당자로 검색...', + }, + { + id: 'invitationStatus', + label: '참여의사', + placeholder: '참여의사로 필터링...', + }, + { + id: 'status', + label: '입찰상태', + placeholder: '입찰상태로 필터링...', + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<PartnersBiddingListItem>[] = [ + { id: 'title', label: '입찰명', type: 'text' }, + { id: 'biddingNumber', label: '입찰번호', type: 'text' }, + { id: 'itemName', label: '품목명', type: 'text' }, + { id: 'projectName', label: '프로젝트명', type: 'text' }, + { id: 'managerName', label: '담당자', type: 'text' }, + { id: 'contractType', label: '계약구분', type: 'text' }, + { id: 'invitationStatus', label: '참여의사', type: 'text' }, + { id: 'status', label: '입찰상태', type: 'text' }, + { id: 'submissionStartDate', label: '입찰시작일', type: 'date' }, + { id: 'submissionEndDate', label: '입찰마감일', type: 'date' }, + { id: 'responseDeadline', label: '참여회신마감일', type: 'date' }, + { id: 'createdAt', label: '등록일', type: 'date' }, + { id: 'updatedAt', label: '수정일', type: 'date' }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'createdAt', desc: true }], + columnPinning: { right: ['actions'] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + if (isLoading) { + return ( + <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> + ) + } + + return ( + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + ) +} |
