diff options
Diffstat (limited to 'lib/bidding')
| -rw-r--r-- | lib/bidding/detail/service.ts | 65 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-header.tsx | 101 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx | 41 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/service.ts | 48 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx | 2 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 41 | ||||
| -rw-r--r-- | lib/bidding/vendor/components/pr-items-pricing-table.tsx | 6 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-detail.tsx | 190 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-pre-quote.tsx | 51 |
9 files changed, 309 insertions, 236 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index d9bcb255..e22331bb 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -1,7 +1,7 @@ 'use server' import db from '@/db/db' -import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions, priceAdjustmentForms } from '@/db/schema' +import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions, priceAdjustmentForms, users } from '@/db/schema' import { specificationMeetings } from '@/db/schema/bidding' import { eq, and, sql, desc, ne } from 'drizzle-orm' import { revalidatePath, revalidateTag } from 'next/cache' @@ -9,6 +9,22 @@ import { unstable_cache } from "@/lib/unstable-cache"; import { sendEmail } from '@/lib/mail/sendEmail' import { saveFile } from '@/lib/file-stroage' +// userId를 user.name으로 변환하는 유틸리티 함수 +async function getUserNameById(userId: string): Promise<string> { + try { + const user = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, parseInt(userId))) + .limit(1) + + return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환 + } catch (error) { + console.error('Failed to get user name:', error) + return userId // 에러 시 userId를 그대로 반환 + } +} + // 데이터 조회 함수들 export interface BiddingDetailData { bidding: Awaited<ReturnType<typeof getBiddingById>> @@ -266,6 +282,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null, isBiddingParticipated: vendor.isBiddingParticipated, status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected', + documents: [], // 빈 배열로 초기화 })) } catch (error) { console.error('Failed to get quotation vendors:', error) @@ -664,22 +681,20 @@ export async function createBiddingDetailVendor( // 협력업체 정보 저장 - biddingCompanies와 companyConditionResponses 테이블에 레코드 생성 export async function createQuotationVendor(input: any, userId: string) { try { + const userName = await getUserNameById(userId) const result = await db.transaction(async (tx) => { // 1. biddingCompanies에 레코드 생성 const biddingCompanyResult = await tx.insert(biddingCompanies).values({ biddingId: input.biddingId, companyId: input.vendorId, - quotationAmount: input.quotationAmount, - currency: input.currency, - status: input.status, - awardRatio: input.awardRatio, + finalQuoteAmount: input.quotationAmount?.toString(), + awardRatio: input.awardRatio?.toString(), isWinner: false, contactPerson: input.contactPerson, contactEmail: input.contactEmail, contactPhone: input.contactPhone, - submissionDate: new Date(), - createdBy: userId, - updatedBy: userId, + finalQuoteSubmittedAt: new Date(), + // 스키마에 createdBy, updatedBy 필드가 없으므로 제거 }).returning({ id: biddingCompanies.id }) if (biddingCompanyResult.length === 0) { @@ -728,6 +743,7 @@ export async function createQuotationVendor(input: any, userId: string) { // 협력업체 정보 업데이트 export async function updateQuotationVendor(id: number, input: any, userId: string) { try { + const userName = await getUserNameById(userId) const result = await db.transaction(async (tx) => { // 1. biddingCompanies 테이블 업데이트 const updateData: any = {} @@ -735,9 +751,9 @@ export async function updateQuotationVendor(id: number, input: any, userId: stri 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 + if (input.awardRatio !== undefined) updateData.awardRatio = input.awardRatio?.toString() + // status 필드가 스키마에 없으므로 제거 + // updatedBy 필드가 스키마에 없으므로 제거 updateData.updatedAt = new Date() if (Object.keys(updateData).length > 0) { @@ -1031,8 +1047,10 @@ export async function registerBidding(biddingId: number, userId: string) { // 캐시 무효화 revalidateTag(`bidding-${biddingId}`) + revalidateTag('bidding-detail') revalidateTag('quotation-vendors') revalidateTag('quotation-details') + revalidateTag('pr-items') revalidatePath(`/evcp/bid/${biddingId}`) return { @@ -1157,6 +1175,7 @@ export async function createRebidding(biddingId: number, userId: string) { // 업체 선정 사유 업데이트 export async function updateVendorSelectionReason(biddingId: number, selectedCompanyId: number, selectionReason: string, userId: string) { try { + const userName = await getUserNameById(userId) // vendorSelectionResults 테이블에 삽입 또는 업데이트 await db .insert(vendorSelectionResults) @@ -1164,7 +1183,7 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom biddingId, selectedCompanyId, selectionReason, - selectedBy: userId, + selectedBy: userName, selectedAt: new Date(), createdAt: new Date(), updatedAt: new Date() @@ -1174,7 +1193,7 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom set: { selectedCompanyId, selectionReason, - selectedBy: userId, + selectedBy: userName, selectedAt: new Date(), updatedAt: new Date() } @@ -1194,6 +1213,7 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom // 낙찰용 문서 업로드 export async function uploadAwardDocument(biddingId: number, file: File, userId: string) { try { + const userName = await getUserNameById(userId) const saveResult = await saveFile({ file, directory: `biddings/${biddingId}/award`, @@ -1211,10 +1231,9 @@ export async function uploadAwardDocument(biddingId: number, file: File, userId: documentType: 'other', title: '낙찰 관련 문서', description: '낙찰 관련 첨부파일', - uploadedBy: userId, + uploadedBy: userName, uploadedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date() + // createdAt, updatedAt 필드가 스키마에 없으므로 제거 }).returning() return { @@ -1292,6 +1311,7 @@ export async function getAwardDocumentForDownload(documentId: number, biddingId: // 낙찰용 문서 삭제 export async function deleteAwardDocument(documentId: number, biddingId: number, userId: string) { try { + const userName = await getUserNameById(userId) // 문서 정보 조회 const documents = await db .select() @@ -1300,7 +1320,7 @@ export async function deleteAwardDocument(documentId: number, biddingId: number, eq(biddingDocuments.id, documentId), eq(biddingDocuments.biddingId, biddingId), eq(biddingDocuments.documentType, 'other'), - eq(biddingDocuments.uploadedBy, userId) + eq(biddingDocuments.uploadedBy, userName) )) .limit(1) @@ -1335,6 +1355,7 @@ export async function deleteAwardDocument(documentId: number, biddingId: number, // 낙찰 처리 (발주비율과 함께) export async function awardBidding(biddingId: number, selectionReason: string, userId: string) { try { + const userName = await getUserNameById(userId) // 낙찰된 업체들 조회 (isWinner가 true인 업체들) const awardedCompanies = await db .select({ @@ -1388,7 +1409,7 @@ export async function awardBidding(biddingId: number, selectionReason: string, u .set({ selectedCompanyId: firstAwardedCompany.companyId, selectionReason, - selectedBy: userId, + selectedBy: userName, selectedAt: new Date(), updatedAt: new Date() }) @@ -1401,7 +1422,7 @@ export async function awardBidding(biddingId: number, selectionReason: string, u biddingId, selectedCompanyId: firstAwardedCompany.companyId, selectionReason, - selectedBy: userId, + selectedBy: userName, selectedAt: new Date(), createdAt: new Date(), updatedAt: new Date() @@ -1532,6 +1553,7 @@ export async function saveBiddingDraft( userId: string ) { try { + const userName = await getUserNameById(userId) let totalAmount = 0 await db.transaction(async (tx) => { @@ -1668,7 +1690,7 @@ export interface PartnersBiddingListItem { // biddings 정보 biddingId: number biddingNumber: string - revision: number + revision: number | null projectName: string itemName: string title: string @@ -1883,6 +1905,7 @@ export async function submitPartnerResponse( userId: string ) { try { + const userName = await getUserNameById(userId) const result = await db.transaction(async (tx) => { // 0. 품목별 견적 정보 최종 저장 (본입찰 제출) - Upsert 방식 if (response.prItemQuotations && response.prItemQuotations.length > 0) { @@ -1981,7 +2004,7 @@ export async function submitPartnerResponse( const companyUpdateData: any = { respondedAt: new Date(), updatedAt: new Date(), - // updatedBy: userId, // 이 필드가 존재하지 않음 + // updatedBy: userName, // 이 필드가 존재하지 않음 } if (response.finalQuoteAmount !== undefined) { diff --git a/lib/bidding/detail/table/bidding-detail-header.tsx b/lib/bidding/detail/table/bidding-detail-header.tsx index 2798478c..8d18472f 100644 --- a/lib/bidding/detail/table/bidding-detail-header.tsx +++ b/lib/bidding/detail/table/bidding-detail-header.tsx @@ -40,107 +40,6 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) { 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', - }) - } - }) - } - return ( <div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <div className="px-6 py-4"> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index 64c31633..654d9941 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -106,7 +106,6 @@ export function BiddingDetailVendorToolbarActions({ <> <div className="flex items-center gap-2"> {/* 상태별 액션 버튼 */} - {bidding.status === 'set_target_price' && ( <Button variant="default" size="sm" @@ -116,27 +115,25 @@ export function BiddingDetailVendorToolbarActions({ <Send className="mr-2 h-4 w-4" /> 입찰 등록 </Button> - )} - <> - <Button - variant="destructive" - size="sm" - onClick={handleMarkAsDisposal} - disabled={isPending} - > - <XCircle className="mr-2 h-4 w-4" /> - 유찰 - </Button> - <Button - variant="default" - size="sm" - onClick={onOpenAwardDialog} - disabled={isPending} - > - <Trophy className="mr-2 h-4 w-4" /> - 낙찰 - </Button> - </> + <Button + variant="destructive" + size="sm" + onClick={handleMarkAsDisposal} + disabled={isPending} + > + <XCircle className="mr-2 h-4 w-4" /> + 유찰 + </Button> + <Button + variant="default" + size="sm" + onClick={onOpenAwardDialog} + disabled={isPending} + > + <Trophy className="mr-2 h-4 w-4" /> + 낙찰 + </Button> + {bidding.status === 'bidding_disposal' && ( <Button diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 3f1b916c..e1df986e 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -3,10 +3,28 @@ import db from '@/db/db' import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding' import { vendors } from '@/db/schema/vendors' +import { users } from '@/db/schema' import { sendEmail } from '@/lib/mail/sendEmail' import { eq, inArray, and } from 'drizzle-orm' import { saveFile } from '@/lib/file-stroage' import { downloadFile } from '@/lib/file-download' +import { revalidateTag, revalidatePath } from 'next/cache' + +// userId를 user.name으로 변환하는 유틸리티 함수 +async function getUserNameById(userId: string): Promise<string> { + try { + const user = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, parseInt(userId))) + .limit(1) + + return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환 + } catch (error) { + console.error('Failed to get user name:', error) + return userId // 에러 시 userId를 그대로 반환 + } +} interface CreateBiddingCompanyInput { biddingId: number @@ -130,6 +148,13 @@ export async function updateBiddingCompany(id: number, input: UpdateBiddingCompa // 본입찰 등록 상태 업데이트 (복수 업체 선택 가능) export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean, userId: string) { try { + // 업체들의 입찰 ID 조회 (캐시 무효화를 위해) + const companies = await db + .select({ biddingId: biddingCompanies.biddingId }) + .from(biddingCompanies) + .where(inArray(biddingCompanies.id, companyIds)) + .limit(1) + await db.update(biddingCompanies) .set({ isPreQuoteSelected: isSelected, @@ -138,6 +163,16 @@ export async function updatePreQuoteSelection(companyIds: number[], isSelected: }) .where(inArray(biddingCompanies.id, companyIds)) + // 캐시 무효화 + if (companies.length > 0) { + const biddingId = companies[0].biddingId + revalidateTag(`bidding-${biddingId}`) + revalidateTag('bidding-detail') + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/evcp/bid/${biddingId}`) + } + const message = isSelected ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.` : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.` @@ -913,6 +948,7 @@ export async function uploadPreQuoteDocument( userId: string ) { try { + const userName = await getUserNameById(userId) // 파일 저장 const saveResult = await saveFile({ file, @@ -943,7 +979,7 @@ export async function uploadPreQuoteDocument( description: '협력업체 제출 견적서', isPublic: false, isRequired: false, - uploadedBy: userId, + uploadedBy: userName, uploadedAt: new Date() }) .returning() @@ -1107,21 +1143,11 @@ export async function deletePreQuoteDocument( const doc = document[0] - // 권한 확인 (업로드한 사용자만 삭제 가능) - if (doc.uploadedBy !== userId) { - return { - success: false, - error: '삭제 권한이 없습니다.' - } - } - // 데이터베이스에서 문서 정보 삭제 await db .delete(biddingDocuments) .where(eq(biddingDocuments.id, documentId)) - // TODO: 실제 파일도 삭제하는 로직 추가 (필요시) - return { success: true, message: '문서가 성공적으로 삭제되었습니다.' diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx index 12bd2696..f676709c 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx @@ -19,6 +19,8 @@ interface PrItem { materialDescription: string | null quantity: string | null quantityUnit: string | null + totalWeight: string | null + weightUnit: string | null currency: string | null requestedDeliveryDate: string | null hasSpecDocument: boolean | null diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index c4904219..7d314841 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -10,7 +10,8 @@ import { prItemsForBidding, specificationMeetings, prDocuments, - biddingConditions + biddingConditions, + users } from '@/db/schema' import { eq, @@ -31,6 +32,22 @@ import { filterColumns } from '@/lib/filter-columns' import { CreateBiddingSchema, GetBiddingsSchema, UpdateBiddingSchema } from './validation' import { saveFile } from '../file-stroage' +// userId를 user.name으로 변환하는 유틸리티 함수 +async function getUserNameById(userId: string): Promise<string> { + try { + const user = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, parseInt(userId))) + .limit(1) + + return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환 + } catch (error) { + console.error('Failed to get user name:', error) + return userId // 에러 시 userId를 그대로 반환 + } +} + export async function getBiddingNoticeTemplate() { try { @@ -368,6 +385,10 @@ export interface CreateBiddingInput extends CreateBiddingSchema { itemInfo: string quantity: string quantityUnit: string + totalWeight: string + weightUnit: string + materialDescription: string + hasSpecDocument: boolean requestedDeliveryDate: string specFiles: File[] isRepresentative: boolean @@ -448,6 +469,7 @@ async function generateBiddingNumber(biddingType: string, tx?: any, maxRetries: // 입찰 생성 export async function createBidding(input: CreateBiddingInput, userId: string) { try { + const userName = await getUserNameById(userId) return await db.transaction(async (tx) => { // 자동 입찰번호 생성 const biddingNumber = await generateBiddingNumber(input.biddingType) @@ -537,8 +559,8 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { managerPhone: input.managerPhone, remarks: input.remarks, - createdBy: userId, - updatedBy: userId, + createdBy: userName, + updatedBy: userName, }) .returning({ id: biddings.id }) @@ -589,7 +611,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { title: `사양설명회 - ${file.name}`, isPublic: false, isRequired: false, - uploadedBy: userId, + uploadedBy: userName, }) } else { console.error(`Failed to save specification meeting file: ${file.name}`, saveResult.error) @@ -672,7 +694,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { description: `PR ${prItem.prNumber}의 스펙 문서`, isPublic: false, isRequired: false, - uploadedBy: userId, + uploadedBy: userName, displayOrder: fileIndex + 1, }) } else { @@ -707,6 +729,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { // 입찰 수정 export async function updateBidding(input: UpdateBiddingInput, userId: string) { try { + const userName = await getUserNameById(userId) // 존재 여부 확인 const existing = await db .select({ id: biddings.id }) @@ -750,7 +773,7 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) { // 업데이트할 데이터 준비 const updateData: any = { updatedAt: new Date(), - updatedBy: userId, + updatedBy: userName, } // 정의된 필드들만 업데이트 @@ -1224,6 +1247,12 @@ export async function getBiddingBasicInfoAction( // 입찰 조건 조회 export async function getBiddingConditions(biddingId: number) { try { + // biddingId가 유효하지 않은 경우 early return + if (!biddingId || isNaN(biddingId) || biddingId <= 0) { + console.warn('Invalid biddingId provided to getBiddingConditions:', biddingId) + return null + } + const conditions = await db .select() .from(biddingConditions) diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index 1dee7adb..13804251 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -286,6 +286,7 @@ export function PrItemsPricingTable({ <TableHead>단위</TableHead> <TableHead>중량</TableHead> <TableHead>중량단위</TableHead> + <TableHead>SHI 납품요청일</TableHead> <TableHead>견적단가</TableHead> <TableHead>견적금액</TableHead> <TableHead>납품예정일</TableHead> @@ -328,6 +329,11 @@ export function PrItemsPricingTable({ </TableCell> <TableCell>{item.weightUnit || '-'}</TableCell> <TableCell> + {item.requestedDeliveryDate ? + formatDate(item.requestedDeliveryDate, 'KR') : '-' + } + </TableCell> + <TableCell> {readOnly ? ( <span className="font-medium"> {quotation.bidUnitPrice.toLocaleString()} diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 8d24ca66..4b316eee 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -60,13 +60,13 @@ interface BiddingDetail { content: string | null contractType: string biddingType: string - awardCount: string + awardCount: string | null contractPeriod: string | null - preQuoteDate: string | null - biddingRegistrationDate: string | null - submissionStartDate: string | null - submissionEndDate: string | null - evaluationDate: string | null + preQuoteDate: Date | null + biddingRegistrationDate: Date | null + submissionStartDate: Date | null + submissionEndDate: Date | null + evaluationDate: Date | null currency: string budget: number | null targetPrice: number | null @@ -78,12 +78,12 @@ interface BiddingDetail { biddingId: number invitationStatus: string finalQuoteAmount: number | null - finalQuoteSubmittedAt: string | null + finalQuoteSubmittedAt: Date | null isWinner: boolean isAttendingMeeting: boolean | null isBiddingParticipated: boolean | null additionalProposals: string | null - responseSubmittedAt: string | null + responseSubmittedAt: Date | null } interface PrItem { @@ -101,11 +101,11 @@ interface PrItem { hasSpecDocument: boolean | null } -interface PrItemQuotation { +interface BiddingPrItemQuotation { prItemId: number bidUnitPrice: number bidAmount: number - proposedDeliveryDate?: string | null + proposedDeliveryDate?: string technicalSpecification?: string } @@ -122,7 +122,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD // 품목별 견적 관련 상태 const [prItems, setPrItems] = React.useState<PrItem[]>([]) - const [prItemQuotations, setPrItemQuotations] = React.useState<PrItemQuotation[]>([]) + const [prItemQuotations, setPrItemQuotations] = React.useState<BiddingPrItemQuotation[]>([]) const [totalQuotationAmount, setTotalQuotationAmount] = React.useState(0) // 응찰 폼 상태 @@ -169,7 +169,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD prItemId: item.prItemId, bidUnitPrice: item.bidUnitPrice, bidAmount: item.bidAmount, - proposedDeliveryDate: item.proposedDeliveryDate || '', + proposedDeliveryDate: item.proposedDeliveryDate || undefined, technicalSpecification: item.technicalSpecification || undefined })) @@ -219,14 +219,43 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD if (result.success) { toast({ - title: '성공', - description: result.message, + title: participated ? '참여 확정' : '미참여 확정', + description: participated + ? '입찰에 참여하셨습니다. 이제 견적을 작성할 수 있습니다.' + : '입찰 참여를 거절하셨습니다.', }) // 데이터 새로고침 const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId) if (updatedDetail) { setBiddingDetail(updatedDetail) + + // 참여 확정 시 사전견적 데이터가 있다면 로드 + if (participated && updatedDetail.biddingCompanyId) { + try { + const preQuoteData = await getSavedPrItemQuotations(updatedDetail.biddingCompanyId) + const convertedQuotations = preQuoteData.map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice, + bidAmount: item.bidAmount, + proposedDeliveryDate: item.proposedDeliveryDate || undefined, + technicalSpecification: item.technicalSpecification || undefined + })) + + setPrItemQuotations(convertedQuotations) + const total = convertedQuotations.reduce((sum, q) => sum + q.bidAmount, 0) + setTotalQuotationAmount(total) + + if (total > 0) { + setResponseData(prev => ({ + ...prev, + finalQuoteAmount: total.toString() + })) + } + } catch (error) { + console.error('Failed to load pre-quote data after participation:', error) + } + } } } else { toast({ @@ -248,7 +277,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD } // 품목별 견적 변경 핸들러 - const handleQuotationsChange = (quotations: PrItemQuotation[]) => { + const handleQuotationsChange = (quotations: BiddingPrItemQuotation[]) => { console.log('견적 변경:', quotations) setPrItemQuotations(quotations) } @@ -282,7 +311,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD prItemId: q.prItemId, bidUnitPrice: q.bidUnitPrice, bidAmount: q.bidAmount, - proposedDeliveryDate: q.proposedDeliveryDate || undefined, + proposedDeliveryDate: q.proposedDeliveryDate, technicalSpecification: q.technicalSpecification })) @@ -367,7 +396,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD prItemId: q.prItemId, bidUnitPrice: q.bidUnitPrice, bidAmount: q.bidAmount, - proposedDeliveryDate: q.proposedDeliveryDate || undefined, + proposedDeliveryDate: q.proposedDeliveryDate, technicalSpecification: q.technicalSpecification })) : undefined, }, @@ -445,7 +474,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <div className="flex items-center gap-2 mt-1"> <Badge variant="outline" className="font-mono"> {biddingDetail.biddingNumber} - {biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`} + {biddingDetail.revision && biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`} </Badge> <Badge variant={ biddingDetail.status === 'bidding_disposal' ? 'destructive' : @@ -460,24 +489,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD {/* 입찰 참여여부 상태 표시 */} <div className="flex items-center gap-2"> - {biddingDetail.isBiddingParticipated === null ? ( - <div className="flex items-center gap-2"> - <Badge variant="outline">참여 결정 대기</Badge> - <Button - onClick={() => handleParticipationDecision(false)} - disabled={isUpdatingParticipation} - variant="destructive" - size="sm" - > - <XCircle className="w-4 h-4 mr-1" /> - 미응찰 - </Button> - </div> - ) : ( - <Badge variant={biddingDetail.isBiddingParticipated ? 'default' : 'destructive'}> - {biddingDetail.isBiddingParticipated ? '응찰' : '미응찰'} - </Badge> - )} + <Badge variant={ + biddingDetail.isBiddingParticipated === null ? 'outline' : + biddingDetail.isBiddingParticipated === true ? 'default' : 'destructive' + }> + {biddingDetail.isBiddingParticipated === null ? '참여 결정 대기' : + biddingDetail.isBiddingParticipated === true ? '응찰' : '미응찰'} + </Badge> </div> </div> @@ -516,10 +534,10 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> <div> <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label> - <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div> + <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : biddingDetail.awardCount === 'multiple' ? '복수' : '미설정'}</div> </div> <div> - <Label className="text-sm font-medium text-muted-foreground">담당자</Label> + <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> @@ -527,7 +545,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> </div> - {biddingDetail.budget && ( + {/* {biddingDetail.budget && ( <div> <Label className="text-sm font-medium text-muted-foreground">예산</Label> <div className="flex items-center gap-2 mt-1"> @@ -535,7 +553,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span> </div> </div> - )} + )} */} {/* 일정 정보 */} <div className="pt-4 border-t"> @@ -560,33 +578,73 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD {/* 참여 상태에 따른 섹션 표시 */} {biddingDetail.isBiddingParticipated === false ? ( /* 미응찰 상태 표시 */ - <Card> - <CardHeader> + <Card> + <CardHeader> <CardTitle className="flex items-center gap-2"> <XCircle className="w-5 h-5 text-destructive" /> 입찰 참여 거절 </CardTitle> - </CardHeader> - <CardContent> + </CardHeader> + <CardContent> <div className="text-center py-8"> <XCircle className="w-16 h-16 text-destructive mx-auto mb-4" /> <h3 className="text-lg font-semibold text-destructive mb-2">입찰에 참여하지 않기로 결정했습니다</h3> <p className="text-muted-foreground"> 해당 입찰에 대한 견적 제출 및 관련 기능은 이용할 수 없습니다. </p> - </div> - </CardContent> - </Card> - ) : biddingDetail.isBiddingParticipated === true || biddingDetail.isBiddingParticipated === null ? ( + </div> + </CardContent> + </Card> + ) : biddingDetail.isBiddingParticipated === null ? ( + /* 참여 의사 확인 섹션 */ + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Users className="w-5 h-5" /> + 입찰 참여 의사 확인 + </CardTitle> + </CardHeader> + <CardContent> + <div className="text-center py-8"> + <div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4"> + <Users className="w-8 h-8 text-primary" /> + </div> + <h3 className="text-lg font-semibold mb-2">이 입찰에 참여하시겠습니까?</h3> + <p className="text-muted-foreground mb-6"> + 참여를 선택하시면 견적 작성 및 제출이 가능합니다. + </p> + <div className="flex justify-center gap-4"> + <Button + onClick={() => handleParticipationDecision(true)} + disabled={isUpdatingParticipation} + className="min-w-[120px]" + > + <CheckCircle className="w-4 h-4 mr-2" /> + 참여하기 + </Button> + <Button + onClick={() => handleParticipationDecision(false)} + disabled={isUpdatingParticipation} + variant="destructive" + className="min-w-[120px]" + > + <XCircle className="w-4 h-4 mr-2" /> + 미참여 + </Button> + </div> + </div> + </CardContent> + </Card> + ) : biddingDetail.isBiddingParticipated === true ? ( /* 응찰 폼 섹션 */ - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Send className="w-5 h-5" /> - 응찰하기 - </CardTitle> - </CardHeader> - <CardContent className="space-y-6"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Send className="w-5 h-5" /> + 응찰하기 + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> {/* 품목별 견적 섹션 */} {/* <div className="space-y-2"> <Label htmlFor="finalQuoteAmount">총 견적금액 *</Label> @@ -648,24 +706,22 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD rows={4} /> </div> */} - {/* 응찰 제출 버튼 - 미응찰 상태가 아닐 때만 표시 */} - {(biddingDetail.isBiddingParticipated === true || biddingDetail.isBiddingParticipated === null) && ( + {/* 응찰 제출 버튼 - 참여 확정 상태일 때만 표시 */} <div className="flex justify-end pt-4 gap-2"> <Button - variant="outline" - onClick={handleSaveDraft} - disabled={isSavingDraft || isSubmitting} - className="min-w-[100px]" - > - <Save className="w-4 h-4 mr-2" /> - {isSavingDraft ? '저장 중...' : '임시 저장'} - </Button> + variant="outline" + onClick={handleSaveDraft} + disabled={isSavingDraft || isSubmitting} + className="min-w-[100px]" + > + <Save className="w-4 h-4 mr-2" /> + {isSavingDraft ? '저장 중...' : '임시 저장'} + </Button> <Button onClick={handleSubmitResponse} disabled={isSubmitting || isSavingDraft} className="min-w-[100px]"> <Send className="w-4 h-4 mr-2" /> {isSubmitting ? '제출 중...' : '응찰 제출'} </Button> </div> - )} </CardContent> </Card> ) : null} diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx index fdd05154..29a37cae 100644 --- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx +++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx @@ -381,6 +381,41 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin return } + // 품목별 납품일 검증 + if (prItemQuotations.length > 0) { + for (const quotation of prItemQuotations) { + if (!quotation.proposedDeliveryDate?.trim()) { + const prItem = prItems.find(item => item.id === quotation.prItemId) + toast({ + title: '유효성 오류', + description: `품목 ${prItem?.itemNumber || quotation.prItemId}의 납품예정일을 입력해주세요.`, + variant: 'destructive', + }) + return + } + } + } + + const requiredFields = [ + { value: responseData.proposedContractDeliveryDate, name: '제안 납품일' }, + { value: responseData.paymentTermsResponse, name: '응답 지급조건' }, + { value: responseData.taxConditionsResponse, name: '응답 세금조건' }, + { value: responseData.incotermsResponse, name: '응답 운송조건' }, + { value: responseData.proposedShippingPort, name: '제안 선적지' }, + { value: responseData.proposedDestinationPort, name: '제안 도착지' }, + { value: responseData.sparePartResponse, name: '스페어파트 응답' }, + ] + + const missingField = requiredFields.find(field => !field.value?.trim()) + if (missingField) { + toast({ + title: '유효성 오류', + description: `${missingField.name}을(를) 입력해주세요.`, + variant: 'destructive', + }) + return + } + startTransition(async () => { const submissionData = { preQuoteAmount: totalAmount, // 품목별 계산된 총 금액 사용 @@ -873,7 +908,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin {/* 총 금액 표시 (읽기 전용) */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="space-y-2"> - <Label htmlFor="totalAmount">총 사전견적 금액 *</Label> + <Label htmlFor="totalAmount">총 사전견적 금액 <span className="text-red-500">*</span></Label> <Input id="totalAmount" type="text" @@ -887,7 +922,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin </div> <div className="space-y-2"> - <Label htmlFor="proposedContractDeliveryDate">제안 납품일</Label> + <Label htmlFor="proposedContractDeliveryDate">제안 납품일 <span className="text-red-500">*</span></Label> <Input id="proposedContractDeliveryDate" type="date" @@ -905,7 +940,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="space-y-2"> - <Label htmlFor="paymentTermsResponse">응답 지급조건</Label> + <Label htmlFor="paymentTermsResponse">응답 지급조건 <span className="text-red-500">*</span></Label> <Input id="paymentTermsResponse" value={responseData.paymentTermsResponse} @@ -915,7 +950,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin </div> <div className="space-y-2"> - <Label htmlFor="taxConditionsResponse">응답 세금조건</Label> + <Label htmlFor="taxConditionsResponse">응답 세금조건 <span className="text-red-500">*</span></Label> <Input id="taxConditionsResponse" value={responseData.taxConditionsResponse} @@ -927,7 +962,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="space-y-2"> - <Label htmlFor="incotermsResponse">응답 운송조건</Label> + <Label htmlFor="incotermsResponse">응답 운송조건 <span className="text-red-500">*</span></Label> <Input id="incotermsResponse" value={responseData.incotermsResponse} @@ -937,7 +972,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin </div> <div className="space-y-2"> - <Label htmlFor="proposedShippingPort">제안 선적지</Label> + <Label htmlFor="proposedShippingPort">제안 선적지 <span className="text-red-500">*</span></Label> <Input id="proposedShippingPort" value={responseData.proposedShippingPort} @@ -949,7 +984,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="space-y-2"> - <Label htmlFor="proposedDestinationPort">제안 도착지</Label> + <Label htmlFor="proposedDestinationPort">제안 도착지 <span className="text-red-500">*</span></Label> <Input id="proposedDestinationPort" value={responseData.proposedDestinationPort} @@ -959,7 +994,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin </div> <div className="space-y-2"> - <Label htmlFor="sparePartResponse">스페어파트 응답</Label> + <Label htmlFor="sparePartResponse">스페어파트 응답 <span className="text-red-500">*</span></Label> <Input id="sparePartResponse" value={responseData.sparePartResponse} |
