diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-27 03:08:50 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-27 03:08:50 +0000 |
| commit | 79cfa7ea8f21ae227dbb2843ae536fe876ba7c55 (patch) | |
| tree | f12efae72c62286c1a2e9a3f31d695ca22d83b6e /lib/bidding | |
| parent | e1da84ac863989b9f63b089c09aaa2bbcdc3d6cd (diff) | |
(최겸) 구매 입찰 수정
Diffstat (limited to 'lib/bidding')
| -rw-r--r-- | lib/bidding/actions.ts | 139 | ||||
| -rw-r--r-- | lib/bidding/approval-actions.ts | 88 | ||||
| -rw-r--r-- | lib/bidding/detail/bidding-actions.ts | 136 | ||||
| -rw-r--r-- | lib/bidding/detail/service.ts | 87 | ||||
| -rw-r--r-- | lib/bidding/handlers.ts | 22 | ||||
| -rw-r--r-- | lib/bidding/pre-quote/service.ts | 3 | ||||
| -rw-r--r-- | lib/bidding/receive/biddings-receive-columns.tsx | 12 | ||||
| -rw-r--r-- | lib/bidding/receive/biddings-receive-table.tsx | 64 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 483 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-detail.tsx | 234 |
10 files changed, 435 insertions, 833 deletions
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index 78f07219..0bf2af57 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -655,111 +655,7 @@ async function getUserNameById(userId: string): Promise<string> { } } -// 조기개찰 액션 -export async function earlyOpenBiddingAction(biddingId: number) { - try { - const session = await getServerSession(authOptions) - if (!session?.user?.name) { - return { success: false, message: '인증이 필요합니다.' } - } - - const userName = session.user.name - - return await db.transaction(async (tx) => { - // 1. 입찰 정보 확인 - const [bidding] = await tx - .select({ - id: biddings.id, - status: biddings.status, - submissionEndDate: biddings.submissionEndDate, - title: biddings.title - }) - .from(biddings) - .where(eq(biddings.id, biddingId)) - .limit(1) - - if (!bidding) { - return { success: false, message: '입찰 정보를 찾을 수 없습니다.' } - } - - // 2. 입찰서 제출기간 내인지 확인 - const now = new Date() - if (bidding.submissionEndDate && now > bidding.submissionEndDate) { - return { success: false, message: '입찰서 제출기간이 종료되었습니다.' } - } - - // 3. 참여 현황 확인 - const [participationStats] = await tx - .select({ - participantExpected: db.$count(biddingCompanies), - participantParticipated: db.$count(biddingCompanies, eq(biddingCompanies.invitationStatus, 'bidding_submitted')), - participantDeclined: db.$count(biddingCompanies, and( - eq(biddingCompanies.invitationStatus, 'bidding_declined'), - eq(biddingCompanies.biddingId, biddingId) - )), - participantPending: db.$count(biddingCompanies, and( - eq(biddingCompanies.invitationStatus, 'pending'), - eq(biddingCompanies.biddingId, biddingId) - )), - }) - .from(biddingCompanies) - .where(eq(biddingCompanies.biddingId, biddingId)) - - // 실제 SQL 쿼리로 변경 - const [stats] = await tx - .select({ - participantExpected: sql<number>`COUNT(*)`.as('participant_expected'), - participantParticipated: sql<number>`COUNT(CASE WHEN invitation_status = 'bidding_submitted' THEN 1 END)`.as('participant_participated'), - participantDeclined: sql<number>`COUNT(CASE WHEN invitation_status IN ('bidding_declined', 'bidding_cancelled') THEN 1 END)`.as('participant_declined'), - participantPending: sql<number>`COUNT(CASE WHEN invitation_status IN ('pending', 'bidding_sent', 'bidding_accepted') THEN 1 END)`.as('participant_pending'), - }) - .from(biddingCompanies) - .where(eq(biddingCompanies.biddingId, biddingId)) - - const participantExpected = Number(stats.participantExpected) || 0 - const participantParticipated = Number(stats.participantParticipated) || 0 - const participantDeclined = Number(stats.participantDeclined) || 0 - const participantPending = Number(stats.participantPending) || 0 - - // 4. 조기개찰 조건 검증 - // - 미제출 협력사 = 0 - if (participantPending > 0) { - return { success: false, message: `미제출 협력사가 ${participantPending}명 있어 조기개찰이 불가능합니다.` } - } - - // - 참여협력사 + 포기협력사 = 참여예정협력사 - if (participantParticipated + participantDeclined !== participantExpected) { - return { success: false, message: '모든 협력사가 참여 또는 포기하지 않아 조기개찰이 불가능합니다.' } - } - - // 5. 참여협력사 중 최종응찰 버튼을 클릭한 업체들만 있는지 검증 - // bidding_submitted 상태인 업체들이 있는지 확인 (이미 위에서 검증됨) - - // 6. 입찰평가중 상태로 변경 - await tx - .update(biddings) - .set({ - status: 'evaluation_of_bidding', - openedAt: new Date(), - openedBy: userName, - updatedAt: new Date(), - updatedBy: userName, - }) - .where(eq(biddings.id, biddingId)) - - return { success: true, message: '조기개찰이 완료되었습니다.' } - }) - - } catch (error) { - console.error('조기개찰 실패:', error) - return { - success: false, - message: error instanceof Error ? error.message : '조기개찰 중 오류가 발생했습니다.' - } - } -} - -// 개찰 액션 +// 개찰 액션 (조기개찰 포함) export async function openBiddingAction(biddingId: number) { try { const session = await getServerSession(authOptions) @@ -786,10 +682,35 @@ export async function openBiddingAction(biddingId: number) { return { success: false, message: '입찰 정보를 찾을 수 없습니다.' } } - // 2. 입찰서 제출기간이 종료되었는지 확인 const now = new Date() - if (bidding.submissionEndDate && now <= bidding.submissionEndDate) { - return { success: false, message: '입찰서 제출기간이 아직 종료되지 않았습니다.' } + const isDeadlinePassed = bidding.submissionEndDate && now > bidding.submissionEndDate + + // 2. 개찰 가능 여부 확인 + if (!isDeadlinePassed) { + // 마감일이 지나지 않았으면 조기개찰 조건 확인 + // 조기개찰 조건: 모든 대상 업체가 응찰(최종제출)했거나 포기했는지 확인 (미제출 0) + + const [stats] = await tx + .select({ + participantExpected: sql<number>`COUNT(*)`.as('participant_expected'), + participantFinalSubmitted: sql<number>`COUNT(CASE WHEN invitation_status = 'bidding_submitted' THEN 1 END)`.as('participant_final_submitted'), + participantDeclined: sql<number>`COUNT(CASE WHEN invitation_status IN ('bidding_declined', 'bidding_cancelled') THEN 1 END)`.as('participant_declined'), + }) + .from(biddingCompanies) + .where(eq(biddingCompanies.biddingId, biddingId)) + + const participantExpected = Number(stats.participantExpected) || 0 + const participantFinalSubmitted = Number(stats.participantFinalSubmitted) || 0 + const participantDeclined = Number(stats.participantDeclined) || 0 + + // 조건: 전체 대상 = 최종제출 + 포기 + if (participantExpected !== participantFinalSubmitted + participantDeclined) { + const pending = participantExpected - (participantFinalSubmitted + participantDeclined); + return { + success: false, + message: `입찰서 제출기간이 종료되지 않았으며, 최종제출하지 않은 업체가 ${pending}곳 있어 조기개찰할 수 없습니다.` + } + } } // 3. 입찰평가중 상태로 변경 @@ -804,7 +725,7 @@ export async function openBiddingAction(biddingId: number) { }) .where(eq(biddings.id, biddingId)) - return { success: true, message: '개찰이 완료되었습니다.' } + return { success: true, message: isDeadlinePassed ? '개찰이 완료되었습니다.' : '조기개찰이 완료되었습니다.' } }) } catch (error) { diff --git a/lib/bidding/approval-actions.ts b/lib/bidding/approval-actions.ts index dd88164d..3d07d49c 100644 --- a/lib/bidding/approval-actions.ts +++ b/lib/bidding/approval-actions.ts @@ -54,6 +54,18 @@ export async function prepareBiddingApprovalData(data: { biddingId: number; }>; message?: string; + specificationMeeting?: { + meetingDate: string | null; + meetingTime: string | null; + location: string | null; + address: string | null; + contactPerson: string | null; + contactPhone: string | null; + contactEmail: string | null; + agenda: string | null; + materials: string | null; + notes: string | null; + }; }) { // 1. 입찰 정보 조회 (템플릿 변수용) debugLog('[BiddingInvitationApproval] 입찰 정보 조회 시작'); @@ -121,11 +133,68 @@ export async function prepareBiddingApprovalData(data: { debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 시작'); const requestedAt = new Date(); const { mapBiddingInvitationToTemplateVariables } = await import('./handlers'); + + // 사양설명회 정보가 전달되지 않았는데 입찰에 사양설명회가 있는 경우 DB에서 조회 + let specMeetingInfo = data.specificationMeeting; + if (!specMeetingInfo && bidding.hasSpecificationMeeting) { + const { specificationMeetings } = await import('@/db/schema'); + const meetings = await db + .select() + .from(specificationMeetings) + .where(eq(specificationMeetings.biddingId, data.biddingId)) + .limit(1); + + if (meetings.length > 0) { + const m = meetings[0]; + specMeetingInfo = { + meetingDate: m.meetingDate ? m.meetingDate.toISOString() : null, + meetingTime: m.meetingTime, + location: m.location, + address: m.address, + contactPerson: m.contactPerson, + contactPhone: m.contactPhone, + contactEmail: m.contactEmail, + agenda: m.agenda, + materials: m.materials, + notes: m.notes + }; + } + } + const variables = await mapBiddingInvitationToTemplateVariables({ - bidding, - biddingItems: biddingItemsInfo, + bidding: { + ...bidding, + projectName: bidding.projectName || undefined, + itemName: bidding.itemName || undefined, + bidPicName: bidding.bidPicName || undefined, + supplyPicName: bidding.supplyPicName || undefined, + targetPrice: bidding.targetPrice ? Number(bidding.targetPrice) : undefined, + remarks: bidding.remarks || undefined, + submissionStartDate: bidding.submissionStartDate || undefined, + submissionEndDate: bidding.submissionEndDate || undefined, + hasSpecificationMeeting: bidding.hasSpecificationMeeting || undefined, + isUrgent: bidding.isUrgent || undefined, + }, + biddingItems: biddingItemsInfo.map(item => ({ + ...item, + projectName: item.projectName || undefined, + materialGroup: item.materialGroup || undefined, + materialGroupName: item.materialGroupName || undefined, + materialCode: item.materialCode || undefined, + materialCodeName: item.materialCodeName || undefined, + quantity: item.quantity ? Number(item.quantity) : undefined, + purchasingUnit: item.purchasingUnit || undefined, + targetUnitPrice: item.targetUnitPrice ? Number(item.targetUnitPrice) : undefined, + quantityUnit: item.quantityUnit || undefined, + totalWeight: item.totalWeight ? Number(item.totalWeight) : undefined, + weightUnit: item.weightUnit || undefined, + budget: item.budget ? Number(item.budget) : undefined, + targetAmount: item.targetAmount ? Number(item.targetAmount) : undefined, + currency: item.currency || undefined, + })), vendors: data.vendors, message: data.message, + specificationMeeting: specMeetingInfo, requestedAt, }); debugLog('[BiddingInvitationApproval] 템플릿 변수 매핑 완료', { @@ -159,6 +228,18 @@ export async function requestBiddingInvitationWithApproval(data: { message?: string; currentUser: { id: number; epId: string | null; email?: string }; approvers?: string[]; // Knox EP ID 배열 (결재선) + specificationMeeting?: { + meetingDate: string | null; + meetingTime: string | null; + location: string | null; + address: string | null; + contactPerson: string | null; + contactPhone: string | null; + contactEmail: string | null; + agenda: string | null; + materials: string | null; + notes: string | null; + }; }) { debugLog('[BiddingInvitationApproval] 입찰초대 결재 서버 액션 시작', { biddingId: data.biddingId, @@ -188,7 +269,7 @@ export async function requestBiddingInvitationWithApproval(data: { .update(biddings) .set({ status: 'approval_pending', // 결재 진행중 상태 - updatedBy: Number(data.currentUser.epId), + updatedBy: String(data.currentUser.id), // id를 string으로 변환 updatedAt: new Date() }) .where(eq(biddings.id, data.biddingId)); @@ -203,6 +284,7 @@ export async function requestBiddingInvitationWithApproval(data: { biddingId: data.biddingId, vendors: data.vendors, message: data.message, + specificationMeeting: data.specificationMeeting, }); // 4. 결재 워크플로우 시작 (Saga 패턴) diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts index fb659039..4140ec72 100644 --- a/lib/bidding/detail/bidding-actions.ts +++ b/lib/bidding/detail/bidding-actions.ts @@ -22,7 +22,7 @@ async function getUserNameById(userId: string): Promise<string> { }
}
-// 응찰 취소 서버 액션 (최종제출이 아닌 경우만 가능)
+// 응찰 포기 서버 액션 (최종제출이 아닌 경우만 가능)
export async function cancelBiddingResponse(
biddingCompanyId: number,
userId: string
@@ -63,7 +63,7 @@ export async function cancelBiddingResponse( finalQuoteAmount: null,
finalQuoteSubmittedAt: null,
isFinalSubmission: false,
- invitationStatus: 'bidding_cancelled', // 응찰 취소 상태
+ invitationStatus: 'bidding_cancelled', // 응찰 포기 상태
updatedAt: new Date()
})
.where(eq(biddingCompanies.id, biddingCompanyId))
@@ -86,142 +86,14 @@ export async function cancelBiddingResponse( return {
success: true,
- message: '응찰이 취소되었습니다.'
+ message: '응찰이 포기되었습니다.'
}
})
} catch (error) {
console.error('Failed to cancel bidding response:', error)
return {
success: false,
- error: error instanceof Error ? error.message : '응찰 취소에 실패했습니다.'
+ error: error instanceof Error ? error.message : '응찰 포기에 실패했습니다.'
}
}
}
-
-// 모든 벤더가 최종제출했는지 확인
-export async function checkAllVendorsFinalSubmitted(biddingId: number) {
- try {
- const companies = await db
- .select({
- id: biddingCompanies.id,
- isFinalSubmission: biddingCompanies.isFinalSubmission,
- invitationStatus: biddingCompanies.invitationStatus,
- })
- .from(biddingCompanies)
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isBiddingInvited, true) // 본입찰 초대된 업체만
- )
- )
-
- // 초대된 업체가 없으면 false
- if (companies.length === 0) {
- return {
- allSubmitted: false,
- totalCompanies: 0,
- submittedCompanies: 0
- }
- }
-
- // 모든 업체가 최종제출했는지 확인
- const submittedCompanies = companies.filter(c => c.isFinalSubmission).length
- const allSubmitted = companies.every(c => c.isFinalSubmission)
-
- return {
- allSubmitted,
- totalCompanies: companies.length,
- submittedCompanies
- }
- } catch (error) {
- console.error('Failed to check all vendors final submitted:', error)
- return {
- allSubmitted: false,
- totalCompanies: 0,
- submittedCompanies: 0
- }
- }
-}
-
-// // 개찰 서버 액션 (조기개찰/개찰 구분)
-// export async function performBidOpening(
-// biddingId: number,
-// userId: string,
-// isEarly: boolean = false // 조기개찰 여부
-// ) {
-// try {
-// const userName = await getUserNameById(userId)
-
-// return await db.transaction(async (tx) => {
-// // 1. 입찰 정보 조회
-// const [bidding] = await tx
-// .select({
-// id: biddings.id,
-// status: biddings.status,
-// submissionEndDate: biddings.submissionEndDate,
-// })
-// .from(biddings)
-// .where(eq(biddings.id, biddingId))
-// .limit(1)
-
-// if (!bidding) {
-// return {
-// success: false,
-// error: '입찰 정보를 찾을 수 없습니다.'
-// }
-// }
-
-// // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만)
-// if (bidding.status !== 'evaluation_of_bidding') {
-// return {
-// success: false,
-// error: '입찰평가중 상태에서만 개찰할 수 있습니다.'
-// }
-// }
-
-// // 3. 모든 벤더가 최종제출했는지 확인
-// const checkResult = await checkAllVendorsFinalSubmitted(biddingId)
-// if (!checkResult.allSubmitted) {
-// return {
-// success: false,
-// error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})`
-// }
-// }
-
-// // 4. 조기개찰 여부 결정
-// const now = new Date()
-// const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null
-// const isBeforeDeadline = submissionEndDate && now < submissionEndDate
-
-// // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰
-// const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening'
-
-// // 5. 입찰 상태 변경
-// await tx
-// .update(biddings)
-// .set({
-// status: newStatus,
-// updatedAt: new Date()
-// })
-// .where(eq(biddings.id, biddingId))
-
-// // 캐시 무효화
-// revalidateTag(`bidding-${biddingId}`)
-// revalidateTag('bidding-detail')
-// revalidatePath(`/evcp/bid/${biddingId}`)
-
-// return {
-// success: true,
-// message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`,
-// status: newStatus
-// }
-// })
-// } catch (error) {
-// console.error('Failed to perform bid opening:', error)
-// return {
-// success: false,
-// error: error instanceof Error ? error.message : '개찰에 실패했습니다.'
-// }
-// }
-// }
-
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 8f9bf018..c9aaa66c 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -854,6 +854,7 @@ export async function registerBidding(biddingId: number, userId: string) { // 3. 선정된 업체들에게 본입찰 초대 메일 발송 debugLog('registerBidding: Sending emails...') for (const company of selectedCompanies) { + // 벤더 메인 이메일로 발송 if (company.contactEmail) { try { await sendEmail({ @@ -879,6 +880,51 @@ export async function registerBidding(biddingId: number, userId: string) { debugError(`Failed to send bidding invitation email to ${company.contactEmail}:`, emailError) } } + + // 추가 담당자들에게도 이메일 발송 + try { + const contactInfos = await db + .select({ + contactName: biddingCompaniesContacts.contactName, + contactEmail: biddingCompaniesContacts.contactEmail + }) + .from(biddingCompaniesContacts) + .where(and( + eq(biddingCompaniesContacts.biddingId, biddingId), + eq(biddingCompaniesContacts.vendorId, company.companyId) + )); + + for (const contact of contactInfos) { + // 벤더 메인 이메일과 중복되지 않는 경우에만 발송 + if (contact.contactEmail && contact.contactEmail !== company.contactEmail) { + try { + await sendEmail({ + to: contact.contactEmail, + template: 'bidding-invitation', + context: { + companyName: company.companyName, + biddingNumber: bidding.biddingNumber, + title: bidding.title, + projectName: bidding.projectName, + itemName: bidding.itemName, + biddingType: bidding.biddingType, + submissionStartDate: bidding.submissionStartDate, + submissionEndDate: bidding.submissionEndDate, + biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`, + bidPicName: bidding.bidPicName, + supplyPicName: bidding.supplyPicName, + language: 'ko' + } + }) + debugLog(`registerBidding: Email sent to contact ${contact.contactEmail}`) + } catch (emailError) { + debugError(`Failed to send bidding invitation email to contact ${contact.contactEmail}:`, emailError) + } + } + } + } catch (contactError) { + debugError('Failed to fetch contact emails:', contactError) + } } // 4. 입찰 공고 SMS 알림 전송 debugLog('registerBidding: Sending SMS...') @@ -1467,6 +1513,41 @@ export async function saveBiddingDraft( } } +// 본입찰용 품목별 견적 조회 (협력업체용) +export async function getPartnerBiddingItemQuotations(biddingCompanyId: number) { + try { + const savedQuotations = await db + .select({ + prItemId: companyPrItemBids.prItemId, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate, + technicalSpecification: companyPrItemBids.technicalSpecification, + currency: companyPrItemBids.currency + }) + .from(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, false) // 본입찰 데이터 + ) + ) + + // Decimal 타입을 number로 변환 + return savedQuotations.map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: parseFloat(item.bidUnitPrice || '0'), + bidAmount: parseFloat(item.bidAmount || '0'), + proposedDeliveryDate: item.proposedDeliveryDate, + technicalSpecification: item.technicalSpecification, + currency: item.currency + })) + } catch (error) { + console.error('Failed to get partner bidding item quotations:', error) + return [] + } +} + // ================================================= // 협력업체 페이지용 함수들 (Partners) // ================================================= @@ -1839,14 +1920,14 @@ export async function submitPartnerResponse( // adjustmentDate: response.priceAdjustmentForm.adjustmentDate || null, // nonApplicableReason: response.priceAdjustmentForm.nonApplicableReason, // } - + // // // 기존 연동제 정보가 있는지 확인 // const existingPriceAdjustment = await tx // .select() // .from(priceAdjustmentForms) // .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId)) // .limit(1) - + // // if (existingPriceAdjustment.length > 0) { // // 업데이트 // await tx @@ -2573,4 +2654,4 @@ export async function setSpecificationMeetingParticipation(biddingCompanyId: num console.error('Failed to update specification meeting participation:', error) return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' } } -}
\ No newline at end of file +} diff --git a/lib/bidding/handlers.ts b/lib/bidding/handlers.ts index 760d7900..11955a39 100644 --- a/lib/bidding/handlers.ts +++ b/lib/bidding/handlers.ts @@ -164,9 +164,21 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { contactEmail?: string | null; }>; message?: string; + specificationMeeting?: { + meetingDate: Date | string | null; + meetingTime: string | null; + location: string | null; + address: string | null; + contactPerson: string | null; + contactPhone: string | null; + contactEmail: string | null; + agenda: string | null; + materials: string | null; + notes: string | null; + }; requestedAt: Date; }): Promise<Record<string, string>> { - const { bidding, biddingItems, vendors, message, requestedAt } = payload; + const { bidding, biddingItems, vendors, message, specificationMeeting, requestedAt } = payload; // 제목 const title = bidding.title || '입찰'; @@ -223,15 +235,15 @@ export async function mapBiddingInvitationToTemplateVariables(payload: { // 사양설명회 정보 const hasSpecMeeting = bidding.hasSpecificationMeeting ? '예' : '아니오'; - const specMeetingStart = bidding.submissionStartDate ? bidding.submissionStartDate.toLocaleString('ko-KR') : ''; - const specMeetingEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : ''; + const specMeetingStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : ''; + const specMeetingEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : ''; const specMeetingStartDup = specMeetingStart; const specMeetingEndDup = specMeetingEnd; // 입찰서제출기간 정보 const submissionPeriodExecution = '예'; // 입찰 기간이 있으므로 예 - const submissionPeriodStart = bidding.submissionStartDate ? bidding.submissionStartDate.toLocaleString('ko-KR') : ''; - const submissionPeriodEnd = bidding.submissionEndDate ? bidding.submissionEndDate.toLocaleString('ko-KR') : ''; + const submissionPeriodStart = bidding.submissionStartDate ? new Date(bidding.submissionStartDate).toISOString().slice(0, 16).replace('T', ' ') : ''; + const submissionPeriodEnd = bidding.submissionEndDate ? new Date(bidding.submissionEndDate).toISOString().slice(0, 16).replace('T', ' ') : ''; // 대상 자재 수 const targetMaterialCount = biddingItems.length.toString(); diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 81daf506..1dd06b3c 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -1496,8 +1496,9 @@ export async function sendBiddingBasicContracts( template: "contract-sign-request",
context: {
vendorName: vendor.vendorName,
+ templateCount: contractTypes.length,
templateName: contractTypes.map(ct => ct.templateName).join(', '),
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/basic-contract`,
+ loginUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/basic-contract`,
language:'ko'
},
});
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx index d5798782..ab2c0d02 100644 --- a/lib/bidding/receive/biddings-receive-columns.tsx +++ b/lib/bidding/receive/biddings-receive-columns.tsx @@ -192,17 +192,17 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co cell: ({ row }) => {
const startDate = row.original.submissionStartDate
const endDate = row.original.submissionEndDate
-
+
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
+
const now = new Date()
- const isActive = now >= startDate && now <= endDate
- const isPast = now > endDate
-
+ const isActive = now >= new Date(startDate) && now <= new Date(endDate)
+ const isPast = now > new Date(endDate)
+
return (
<div className="text-xs">
<div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ {new Date(startDate).toISOString().slice(0, 16).replace('T', ' ')} ~ {new Date(endDate).toISOString().slice(0, 16).replace('T', ' ')}
</div>
{isActive && (
<Badge variant="default" className="text-xs mt-1">진행중</Badge>
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx index 0995c6a2..97d627ea 100644 --- a/lib/bidding/receive/biddings-receive-table.tsx +++ b/lib/bidding/receive/biddings-receive-table.tsx @@ -45,6 +45,7 @@ type BiddingReceiveItem = { participantParticipated: number
participantDeclined: number
participantPending: number
+ participantFinalSubmitted: number
// 개찰 정보
openedAt: Date | null
@@ -72,7 +73,6 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
const [isOpeningBidding, setIsOpeningBidding] = React.useState(false)
- const [isEarlyOpeningBidding, setIsEarlyOpeningBidding] = React.useState(false)
const router = useRouter()
const { data: session } = useSession()
@@ -180,59 +180,22 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedBiddingForAction = selectedRows.length > 0 ? selectedRows[0].original : null
- // 조기개찰 가능 여부 확인
- const canEarlyOpen = React.useMemo(() => {
- if (!selectedBiddingForAction) return false
-
- const now = new Date()
- const submissionEndDate = selectedBiddingForAction.submissionEndDate
-
- // 참여협력사가 1명 이상이어야 함
- if (selectedBiddingForAction.participantParticipated < 1) return false
-
- // 입찰서 제출기간 내여야 함
- if (!submissionEndDate || now > submissionEndDate) return false
-
- // 미제출 협력사가 0이어야 함
- if (selectedBiddingForAction.participantPending > 0) return false
-
- // 참여협력사 + 포기협력사 = 참여예정협력사 여야 함
- const participatedOrDeclined = selectedBiddingForAction.participantParticipated + selectedBiddingForAction.participantDeclined
- return participatedOrDeclined === selectedBiddingForAction.participantExpected
- }, [selectedBiddingForAction])
-
// 개찰 가능 여부 확인
const canOpen = React.useMemo(() => {
if (!selectedBiddingForAction) return false
- // 참여협력사가 1명 이상이어야 함
- if (selectedBiddingForAction.participantParticipated < 1) return false
-
const now = new Date()
const submissionEndDate = selectedBiddingForAction.submissionEndDate
- // 입찰서 제출기간이 종료되어야 함
- return submissionEndDate && now > submissionEndDate
- }, [selectedBiddingForAction])
+ // 1. 입찰 마감일이 지났으면 무조건 가능
+ if (submissionEndDate && now > submissionEndDate) return true
- const handleEarlyOpenBidding = React.useCallback(async () => {
- if (!selectedBiddingForAction) return
+ // 2. 입찰 기간 내 조기개찰 조건 확인
+ // - 미제출 협력사가 0이어야 함 (참여예정 = 최종제출 + 포기)
+ const participatedOrDeclined = selectedBiddingForAction.participantFinalSubmitted + selectedBiddingForAction.participantDeclined
+ const isEarlyOpenPossible = participatedOrDeclined === selectedBiddingForAction.participantExpected
- setIsEarlyOpeningBidding(true)
- try {
- const result = await earlyOpenBiddingAction(selectedBiddingForAction.id)
- if (result.success) {
- toast.success("조기개찰이 완료되었습니다.")
- // 데이터 리프레시
- window.location.reload()
- } else {
- toast.error(result.message || "조기개찰에 실패했습니다.")
- }
- } catch (error) {
- toast.error("조기개찰 중 오류가 발생했습니다.")
- } finally {
- setIsEarlyOpeningBidding(false)
- }
+ return isEarlyOpenPossible
}, [selectedBiddingForAction])
const handleOpenBidding = React.useCallback(async () => {
@@ -270,20 +233,11 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { onCompactChange={handleCompactChange}
>
<div className="flex items-center gap-2">
- {/* <Button
- variant="outline"
- size="sm"
- onClick={handleEarlyOpenBidding}
- disabled={!selectedBiddingForAction || !canEarlyOpen || isEarlyOpeningBidding}
- >
- {isEarlyOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 조기개찰
- </Button> */}
<Button
variant="outline"
size="sm"
onClick={handleOpenBidding}
- disabled={!selectedBiddingForAction || isOpeningBidding}
+ disabled={!selectedBiddingForAction || !canOpen || isOpeningBidding}
>
{isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
개찰
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 521f4c33..489268c6 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -33,7 +33,8 @@ import { like, notInArray, inArray, - isNull + isNull, + isNotNull } from 'drizzle-orm' import { revalidatePath } from 'next/cache' import { filterColumns } from '@/lib/filter-columns' @@ -390,429 +391,36 @@ export async function getBiddings(input: GetBiddingsSchema) { id: biddings.id, biddingNumber: biddings.biddingNumber, originalBiddingNumber: biddings.originalBiddingNumber, - revision: biddings.revision, projectName: biddings.projectName, - itemName: biddings.itemName, title: biddings.title, - description: biddings.description, - biddingSourceType: biddings.biddingSourceType, - isUrgent: biddings.isUrgent, // 계약 정보 contractType: biddings.contractType, biddingType: biddings.biddingType, - awardCount: biddings.awardCount, - contractStartDate: biddings.contractStartDate, - contractEndDate: biddings.contractEndDate, // 일정 관리 - preQuoteDate: biddings.preQuoteDate, biddingRegistrationDate: biddings.biddingRegistrationDate, submissionStartDate: biddings.submissionStartDate, submissionEndDate: biddings.submissionEndDate, - evaluationDate: biddings.evaluationDate, // 회의 및 문서 hasSpecificationMeeting: biddings.hasSpecificationMeeting, - hasPrDocument: biddings.hasPrDocument, - prNumber: biddings.prNumber, // 가격 정보 currency: biddings.currency, budget: biddings.budget, targetPrice: biddings.targetPrice, - finalBidPrice: biddings.finalBidPrice, // 상태 및 담당자 status: biddings.status, - isPublic: biddings.isPublic, - purchasingOrganization: biddings.purchasingOrganization, - bidPicId: biddings.bidPicId, bidPicName: biddings.bidPicName, - supplyPicId: biddings.supplyPicId, - supplyPicName: biddings.supplyPicName, // 메타 정보 remarks: biddings.remarks, - createdBy: biddings.createdBy, - createdAt: biddings.createdAt, updatedAt: biddings.updatedAt, updatedBy: biddings.updatedBy, - - // 사양설명회 상세 정보 - hasSpecificationMeetingDetails: sql<boolean>`${specificationMeetings.id} IS NOT NULL`.as('has_specification_meeting_details'), - meetingDate: specificationMeetings.meetingDate, - meetingLocation: specificationMeetings.location, - meetingContactPerson: specificationMeetings.contactPerson, - meetingIsRequired: specificationMeetings.isRequired, - - // PR 문서 집계 - prDocumentCount: sql<number>` - COALESCE(( - SELECT count(*) - FROM pr_documents - WHERE bidding_id = ${biddings.id} - ), 0) - `.as('pr_document_count'), - - prDocumentNames: sql<string[]>` - ( - SELECT array_agg(document_name ORDER BY registered_at DESC) - FROM pr_documents - WHERE bidding_id = ${biddings.id} - LIMIT 5 - ) - `.as('pr_document_names'), - - // 참여 현황 집계 (전체) - participantExpected: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - ), 0) - `.as('participant_expected'), - - // === 사전견적 참여 현황 === - preQuotePending: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status IN ('pending', 'pre_quote_sent') - ), 0) - `.as('pre_quote_pending'), - - preQuoteAccepted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'pre_quote_accepted' - ), 0) - `.as('pre_quote_accepted'), - - preQuoteDeclined: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'pre_quote_declined' - ), 0) - `.as('pre_quote_declined'), - - preQuoteSubmitted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'pre_quote_submitted' - ), 0) - `.as('pre_quote_submitted'), - - // === 입찰 참여 현황 === - biddingPending: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_sent' - ), 0) - `.as('bidding_pending'), - - biddingAccepted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_accepted' - ), 0) - `.as('bidding_accepted'), - - biddingDeclined: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_declined' - ), 0) - `.as('bidding_declined'), - - biddingCancelled: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_cancelled' - ), 0) - `.as('bidding_cancelled'), - - biddingSubmitted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_submitted' - ), 0) - `.as('bidding_submitted'), - - // === 호환성을 위한 기존 컬럼 (사전견적 기준) === - participantParticipated: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'pre_quote_submitted' - ), 0) - `.as('participant_participated'), - - participantDeclined: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status IN ('pre_quote_declined', 'bidding_declined') - ), 0) - `.as('participant_declined'), - - participantPending: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status IN ('pending', 'pre_quote_sent', 'bidding_sent') - ), 0) - `.as('participant_pending'), - - participantAccepted: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status IN ('pre_quote_accepted', 'bidding_accepted') - ), 0) - `.as('participant_accepted'), - - // 참여율 계산 (입찰 기준 - 응찰 완료 / 전체) - participationRate: sql<number>` - CASE - WHEN ( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - ) > 0 - THEN ROUND( - ( - SELECT count(*)::decimal - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND invitation_status = 'bidding_submitted' - ) / ( - SELECT count(*)::decimal - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - ) * 100, 1 - ) - ELSE 0 - END - `.as('participation_rate'), - - // 견적 금액 통계 - avgPreQuoteAmount: sql<number>` - ( - SELECT AVG(pre_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND pre_quote_amount IS NOT NULL - ) - `.as('avg_pre_quote_amount'), - - minPreQuoteAmount: sql<number>` - ( - SELECT MIN(pre_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND pre_quote_amount IS NOT NULL - ) - `.as('min_pre_quote_amount'), - - maxPreQuoteAmount: sql<number>` - ( - SELECT MAX(pre_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND pre_quote_amount IS NOT NULL - ) - `.as('max_pre_quote_amount'), - - avgFinalQuoteAmount: sql<number>` - ( - SELECT AVG(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) - `.as('avg_final_quote_amount'), - - minFinalQuoteAmount: sql<number>` - ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) - `.as('min_final_quote_amount'), - - maxFinalQuoteAmount: sql<number>` - ( - SELECT MAX(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) - `.as('max_final_quote_amount'), - - // 선정 및 낙찰 정보 - selectedForFinalBidCount: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND is_pre_quote_selected = true - ), 0) - `.as('selected_for_final_bid_count'), - - winnerCount: sql<number>` - COALESCE(( - SELECT count(*) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND is_winner = true - ), 0) - `.as('winner_count'), - - winnerCompanyNames: sql<string[]>` - ( - SELECT array_agg(v.vendor_name ORDER BY v.vendor_name) - FROM bidding_companies bc - JOIN vendors v ON bc.company_id = v.id - WHERE bc.bidding_id = ${biddings.id} - AND bc.is_winner = true - ) - `.as('winner_company_names'), - - // 일정 상태 계산 - submissionStatus: sql<string>` - CASE - WHEN ${biddings.submissionStartDate} IS NULL OR ${biddings.submissionEndDate} IS NULL - THEN 'not_scheduled' - WHEN NOW() < ${biddings.submissionStartDate} - THEN 'scheduled' - WHEN NOW() BETWEEN ${biddings.submissionStartDate} AND ${biddings.submissionEndDate} - THEN 'active' - WHEN NOW() > ${biddings.submissionEndDate} - THEN 'closed' - ELSE 'unknown' - END - `.as('submission_status'), - - // 마감까지 남은 일수 - daysUntilDeadline: sql<number>` - CASE - WHEN ${biddings.submissionEndDate} IS NOT NULL - AND NOW() < ${biddings.submissionEndDate} - THEN EXTRACT(DAYS FROM (${biddings.submissionEndDate} - NOW()))::integer - ELSE NULL - END - `.as('days_until_deadline'), - - // 시작까지 남은 일수 - daysUntilStart: sql<number>` - CASE - WHEN ${biddings.submissionStartDate} IS NOT NULL - AND NOW() < ${biddings.submissionStartDate} - THEN EXTRACT(DAYS FROM (${biddings.submissionStartDate} - NOW()))::integer - ELSE NULL - END - `.as('days_until_start'), - - // 예산 대비 최저 견적 비율 - budgetEfficiencyRate: sql<number>` - CASE - WHEN ${biddings.budget} IS NOT NULL AND ${biddings.budget} > 0 - AND ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) IS NOT NULL - THEN ROUND( - ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) / ${biddings.budget} * 100, 1 - ) - ELSE NULL - END - `.as('budget_efficiency_rate'), - - // 내정가 대비 최저 견적 비율 - targetPriceEfficiencyRate: sql<number>` - CASE - WHEN ${biddings.targetPrice} IS NOT NULL AND ${biddings.targetPrice} > 0 - AND ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) IS NOT NULL - THEN ROUND( - ( - SELECT MIN(final_quote_amount) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - AND final_quote_amount IS NOT NULL - ) / ${biddings.targetPrice} * 100, 1 - ) - ELSE NULL - END - `.as('target_price_efficiency_rate'), - - // 입찰 진행 단계 점수 (0-100) - progressScore: sql<number>` - CASE ${biddings.status} - WHEN 'bidding_generated' THEN 10 - WHEN 'request_for_quotation' THEN 20 - WHEN 'received_quotation' THEN 40 - WHEN 'set_target_price' THEN 60 - WHEN 'bidding_opened' THEN 70 - WHEN 'bidding_closed' THEN 80 - WHEN 'evaluation_of_bidding' THEN 90 - WHEN 'vendor_selected' THEN 100 - WHEN 'bidding_disposal' THEN 0 - ELSE 0 - END - `.as('progress_score'), - - // 마지막 활동일 (가장 최근 업체 응답일) - lastActivityDate: sql<Date>` - GREATEST( - ${biddings.updatedAt}, - COALESCE(( - SELECT MAX(updated_at) - FROM bidding_companies - WHERE bidding_id = ${biddings.id} - ), ${biddings.updatedAt}) - ) - `.as('last_activity_date'), }) .from(biddings) - .leftJoin( - specificationMeetings, - sql`${biddings.id} = ${specificationMeetings.biddingId}` - ) .where(finalWhere) .orderBy(...orderByColumns) .limit(input.perPage) @@ -995,8 +603,8 @@ export async function getParticipantCountsForBidding(biddingId: number) { const expected = expectedResult[0]?.count || 0 - // 참여 완료 수 - const participatedResult = await db + // 최종 제출 완료 수 (Strict) + const finalSubmittedResult = await db .select({ count: count() }) .from(biddingCompanies) .where(and( @@ -1004,6 +612,23 @@ export async function getParticipantCountsForBidding(biddingId: number) { eq(biddingCompanies.invitationStatus, 'bidding_submitted') )) + const finalSubmitted = finalSubmittedResult[0]?.count || 0 + + // 참여 완료 수 (Broad: 최종제출 OR (참여수락 AND 견적제출)) + const participatedResult = await db + .select({ count: count() }) + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + or( + eq(biddingCompanies.invitationStatus, 'bidding_submitted'), + and( + eq(biddingCompanies.invitationStatus, 'bidding_accepted'), + isNotNull(biddingCompanies.finalQuoteAmount) + ) + ) + )) + const participated = participatedResult[0]?.count || 0 // 거부/취소 수 @@ -1020,7 +645,8 @@ export async function getParticipantCountsForBidding(biddingId: number) { const declined = declinedResult[0]?.count || 0 - // 대기중 수 + // 대기중 수 (Expected - Participated - Declined) + // 또는: Pending OR Sent OR (Accepted AND Quote IS NULL) const pendingResult = await db .select({ count: count() }) .from(biddingCompanies) @@ -1029,7 +655,10 @@ export async function getParticipantCountsForBidding(biddingId: number) { or( eq(biddingCompanies.invitationStatus, 'pending'), eq(biddingCompanies.invitationStatus, 'bidding_sent'), - eq(biddingCompanies.invitationStatus, 'bidding_accepted') + and( + eq(biddingCompanies.invitationStatus, 'bidding_accepted'), + isNull(biddingCompanies.finalQuoteAmount) + ) ) )) @@ -1039,7 +668,8 @@ export async function getParticipantCountsForBidding(biddingId: number) { participantExpected: expected, participantParticipated: participated, participantDeclined: declined, - participantPending: pending + participantPending: pending, + participantFinalSubmitted: finalSubmitted } } catch (error) { console.error('Error in getParticipantCountsForBidding:', error) @@ -1047,7 +677,8 @@ export async function getParticipantCountsForBidding(biddingId: number) { participantExpected: 0, participantParticipated: 0, participantDeclined: 0, - participantPending: 0 + participantPending: 0, + participantFinalSubmitted: 0 } } } @@ -1614,8 +1245,8 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) { .set(updateData) .where(eq(biddings.id, input.id)) - revalidatePath('/admin/biddings') - revalidatePath(`/admin/biddings/${input.id}`) + revalidatePath('/evcp/bid') + revalidatePath(`/evcp/bid/${input.id}/info`) return { success: true, @@ -1651,7 +1282,7 @@ export async function deleteBidding(id: number) { .delete(biddings) .where(eq(biddings.id, id)) - revalidatePath('/admin/biddings') + revalidatePath('/evcp/bid') return { success: true, @@ -3539,14 +3170,48 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u invitedAt: new Date(), invitationStatus: 'pending' as const, // 초대 대기 상태로 초기화 // 제출 정보는 초기화 - submittedAt: null, - quotationPrice: null, - quotationCurrency: null, - quotationValidityDays: null, - deliveryDate: null, - remarks: null, + finalQuoteAmount: null, + finalQuoteSubmittedAt: null, + isFinalSubmission: false, + isWinner: null, + awardRatio: null, + respondedAt: null, + + // 연락처 정보 복제 + contactPerson: company.contactPerson, + contactEmail: company.contactEmail, + contactPhone: company.contactPhone, + + // 본입찰 대상 선정 여부 복제 (중요: 차수증가 시에도 대상 업체 유지) + isPreQuoteSelected: company.isPreQuoteSelected, + // 본입찰 참여 여부 초기화 (다시 참여해야 함) + isBiddingParticipated: null, + // 본입찰 초대 여부 초기화 (다시 초대해야 함) + isBiddingInvited: false, + + notes: company.notes, })) ) + + // 6-1. 벤더 담당자 복제 (추가) + const existingContacts = await tx + .select() + .from(biddingCompaniesContacts) + .where(eq(biddingCompaniesContacts.biddingId, biddingId)) + + if (existingContacts.length > 0) { + await tx + .insert(biddingCompaniesContacts) + .values( + existingContacts.map((contact) => ({ + biddingId: newBidding.id, + vendorId: contact.vendorId, + contactName: contact.contactName, + contactEmail: contact.contactEmail, + contactNumber: contact.contactNumber, + })) + ) + } } // 7. 사양설명회 정보 복제 (있는 경우) diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 10fe71a9..03429cca 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -32,10 +32,11 @@ import { submitPartnerResponse, updatePartnerBiddingParticipation, saveBiddingDraft, - getPriceAdjustmentFormByBiddingCompanyId + getPriceAdjustmentFormByBiddingCompanyId, + getPartnerBiddingItemQuotations } from '../detail/service' import { cancelBiddingResponse } from '../detail/bidding-actions' -import { getPrItemsForBidding, getSavedPrItemQuotations } from '@/lib/bidding/pre-quote/service' +import { getPrItemsForBidding } from '@/lib/bidding/pre-quote/service' import { getBiddingConditions } from '@/lib/bidding/service' import { PrItemsPricingTable } from './components/pr-items-pricing-table' import { SimpleFileUpload } from './components/simple-file-upload' @@ -68,13 +69,13 @@ interface BiddingDetail { contractType: string biddingType: string awardCount: string | null - contractStartDate: Date | null - contractEndDate: Date | null - preQuoteDate: Date | null - biddingRegistrationDate: Date | null - submissionStartDate: Date | null - submissionEndDate: Date | null - evaluationDate: Date | null + contractStartDate: Date | string | null + contractEndDate: Date | string | null + preQuoteDate: Date | string | null + biddingRegistrationDate: Date | string | null + submissionStartDate: Date | string | null + submissionEndDate: Date | string | null + evaluationDate: Date | string | null currency: string budget: number | null targetPrice: number | null @@ -109,7 +110,7 @@ interface PrItem { materialGroupInfo: string | null materialNumber: string | null materialInfo: string | null - requestedDeliveryDate: Date | null + requestedDeliveryDate: Date | string | null annualUnitPrice: string | null currency: string | null quantity: string | null @@ -160,16 +161,17 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const [isCancelling, setIsCancelling] = React.useState(false) const [isFinalSubmission, setIsFinalSubmission] = React.useState(false) const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false) + const [isExpired, setIsExpired] = React.useState(false) // 입찰공고 관련 상태 const [biddingNotice, setBiddingNotice] = React.useState<{ id?: number - biddingId?: number + biddingId?: number | null title?: string content?: string isTemplate?: boolean - createdAt?: string - updatedAt?: string + createdAt?: string | Date + updatedAt?: string | Date } | null>(null) const [biddingConditions, setBiddingConditions] = React.useState<BiddingConditions | null>(null) const [isNoticeOpen, setIsNoticeOpen] = React.useState(false) @@ -274,6 +276,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD }) setBiddingDetail(result) + + // 만료 여부 확인 + if (result.submissionEndDate) { + const now = new Date() + const deadline = new Date(result.submissionEndDate) + setIsExpired(deadline < now) + } // 기존 응답 데이터로 폼 초기화 setResponseData({ @@ -297,7 +306,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD if (result?.biddingCompanyId) { try { // 입찰 데이터를 가져와서 본입찰용으로 변환 - const preQuoteData = await getSavedPrItemQuotations(result.biddingCompanyId) + const preQuoteData = await getPartnerBiddingItemQuotations(result.biddingCompanyId) if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) { console.log('입찰 데이터:', preQuoteData) @@ -325,19 +334,19 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD }, 0) setTotalQuotationAmount(total) console.log('계산된 총 금액:', total) + + // 응찰 확정 시에만 입찰 금액을 finalQuoteAmount로 설정 + if (total > 0 && result?.isBiddingParticipated === true) { + console.log('응찰 확정됨, 입찰 금액 설정:', total) + console.log('입찰 금액을 finalQuoteAmount로 설정:', total) + setResponseData(prev => ({ + ...prev, + finalQuoteAmount: total.toString() + })) + } } } - // 응찰 확정 시에만 입찰 금액을 finalQuoteAmount로 설정 - if (totalQuotationAmount > 0 && result?.isBiddingParticipated === true) { - console.log('응찰 확정됨, 입찰 금액 설정:', totalQuotationAmount) - console.log('입찰 금액을 finalQuoteAmount로 설정:', totalQuotationAmount) - setResponseData(prev => ({ - ...prev, - finalQuoteAmount: totalQuotationAmount.toString() - })) - } - // 연동제 데이터 로드 (입찰에서 답변했으면 로드, 아니면 입찰 조건 확인) if (result.priceAdjustmentResponse !== null) { // 입찰에서 이미 답변한 경우 - 연동제 폼 로드 @@ -385,6 +394,16 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const handleParticipationDecision = async (participated: boolean) => { if (!biddingDetail) return + // 만료 체크 + if (isExpired) { + toast({ + title: "참여 불가", + description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return + } + setIsUpdatingParticipation(true) try { const result = await updatePartnerBiddingParticipation( @@ -409,7 +428,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD // 참여 확정 시 입찰 데이터가 있다면 로드 if (participated && updatedDetail.biddingCompanyId) { try { - const preQuoteData = await getSavedPrItemQuotations(updatedDetail.biddingCompanyId) + const preQuoteData = await getPartnerBiddingItemQuotations(updatedDetail.biddingCompanyId) if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) { console.log('참여확정 후 입찰 데이터:', preQuoteData) @@ -487,18 +506,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const handleSaveDraft = async () => { if (!biddingDetail || !userId) return - // 제출 마감일 체크 - if (biddingDetail.submissionEndDate) { - const now = new Date() - const deadline = new Date(biddingDetail.submissionEndDate) - if (deadline < now) { - toast({ - title: "접근 제한", - description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", - variant: "destructive", - }) - return - } + // 제출 마감일 체크 (상태 사용) + if (isExpired) { + toast({ + title: "접근 제한", + description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return } // 입찰 마감 상태 체크 @@ -566,7 +581,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD } } - // 응찰 취소 핸들러 + // 응찰 포기 핸들러 const handleCancelResponse = async () => { if (!biddingDetail || !userId) return @@ -590,7 +605,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD if (result.success) { toast({ - title: '응찰 취소 완료', + title: '응찰 포기 완료', description: '응찰이 취소되었습니다.', }) // 페이지 새로고침 @@ -602,7 +617,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD } } else { toast({ - title: '응찰 취소 실패', + title: '응찰 포기 실패', description: result.error, variant: 'destructive', }) @@ -611,7 +626,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD console.error('Failed to cancel bidding response:', error) toast({ title: '오류', - description: '응찰 취소에 실패했습니다.', + description: '응찰 포기에 실패했습니다.', variant: 'destructive', }) } finally { @@ -622,18 +637,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const handleSubmitResponse = () => { if (!biddingDetail) return - // 제출 마감일 체크 - if (biddingDetail.submissionEndDate) { - const now = new Date() - const deadline = new Date(biddingDetail.submissionEndDate) - if (deadline < now) { - toast({ - title: "접근 제한", - description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", - variant: "destructive", - }) - return - } + // 제출 마감일 체크 (상태 사용) + if (isExpired) { + toast({ + title: "접근 제한", + description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return } // 입찰 마감 상태 체크 @@ -692,8 +703,11 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD additionalProposals: responseData.additionalProposals, isFinalSubmission, // 최종제출 여부 추가 // 연동제 데이터 추가 (연동제 적용요건 문의가 있는 경우만) - priceAdjustmentResponse: biddingDetail.isPriceAdjustmentApplicableQuestion ? responseData.priceAdjustmentResponse : undefined, - priceAdjustmentForm: biddingDetail.isPriceAdjustmentApplicableQuestion && responseData.priceAdjustmentResponse !== null ? priceAdjustmentForm : undefined, + priceAdjustmentResponse: biddingDetail.isPriceAdjustmentApplicableQuestion ? (responseData.priceAdjustmentResponse ?? false) as boolean : false, + priceAdjustmentForm: biddingDetail.isPriceAdjustmentApplicableQuestion && (responseData.priceAdjustmentResponse ?? false) as boolean ? { + ...priceAdjustmentForm, + adjustmentRatio: parseFloat(priceAdjustmentForm.adjustmentRatio) || 0 + } : undefined, prItemQuotations: prItemQuotations.length > 0 ? prItemQuotations.map(q => ({ prItemId: q.prItemId, bidUnitPrice: q.bidUnitPrice, @@ -849,57 +863,57 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD {/* 제출 마감일 D-day */} - {biddingDetail.submissionEndDate && ( - <div className="pt-4 border-t"> - <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label> - {(() => { - const now = new Date() - const deadline = new Date(biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')) - const isExpired = deadline < now - const timeLeft = deadline.getTime() - now.getTime() - const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) - const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - - return ( - <div className={`p-3 rounded-lg border-2 ${ - isExpired - ? 'border-red-200 bg-red-50' - : daysLeft <= 1 - ? 'border-orange-200 bg-orange-50' - : 'border-green-200 bg-green-50' - }`}> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <Calendar className="w-5 h-5" /> - <span className="font-medium">제출 마감일:</span> - <span className="text-lg font-semibold"> - {biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')} - </span> + {biddingDetail.submissionEndDate && ( + <div className="pt-4 border-t"> + <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label> + {(() => { + const now = new Date() + const deadline = new Date(biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')) + // isExpired 상태 사용 + const timeLeft = deadline.getTime() - now.getTime() + const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) + const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + + return ( + <div className={`p-3 rounded-lg border-2 ${ + isExpired + ? 'border-red-200 bg-red-50' + : daysLeft <= 1 + ? 'border-orange-200 bg-orange-50' + : 'border-green-200 bg-green-50' + }`}> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Calendar className="w-5 h-5" /> + <span className="font-medium">제출 마감일:</span> + <span className="text-lg font-semibold"> + {biddingDetail.submissionEndDate.toISOString().slice(0, 16).replace('T', ' ')} + </span> + </div> + {isExpired ? ( + <Badge variant="destructive" className="ml-2"> + 마감됨 + </Badge> + ) : daysLeft <= 1 ? ( + <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800"> + {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`} + </Badge> + ) : ( + <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800"> + {daysLeft}일 남음 + </Badge> + )} </div> - {isExpired ? ( - <Badge variant="destructive" className="ml-2"> - 마감됨 - </Badge> - ) : daysLeft <= 1 ? ( - <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800"> - {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`} - </Badge> - ) : ( - <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800"> - {daysLeft}일 남음 - </Badge> + {isExpired && ( + <div className="mt-2 text-sm text-red-600"> + ⚠️ 제출 마감일이 지났습니다. 입찰 제출이 불가능합니다. + </div> )} </div> - {isExpired && ( - <div className="mt-2 text-sm text-red-600"> - ⚠️ 제출 마감일이 지났습니다. 입찰 제출이 불가능합니다. - </div> - )} - </div> - ) - })()} - </div> - )} + ) + })()} + </div> + )} {/* 일정 정보 */} <div className="pt-4 border-t"> @@ -1086,15 +1100,15 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <div className="flex justify-center gap-4"> <Button onClick={() => handleParticipationDecision(true)} - disabled={isUpdatingParticipation} + disabled={isUpdatingParticipation || isExpired} className="min-w-[120px]" > <CheckCircle className="w-4 h-4 mr-2" /> - 참여하기 + {isExpired ? '마감됨' : '참여하기'} </Button> <Button onClick={() => handleParticipationDecision(false)} - disabled={isUpdatingParticipation} + disabled={isUpdatingParticipation || isExpired} variant="destructive" className="min-w-[120px]" > @@ -1407,7 +1421,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD {/* 응찰 제출 버튼 - 참여 확정 상태일 때만 표시 */} <div className="flex justify-between pt-4 gap-2"> - {/* 응찰 취소 버튼 (최종제출 아닌 경우만) */} + {/* 응찰 포기 버튼 (최종제출 아닌 경우만) */} {biddingDetail.finalQuoteSubmittedAt && !biddingDetail.isFinalSubmission && ( <Button variant="destructive" @@ -1416,14 +1430,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD className="min-w-[100px]" > <Trash2 className="w-4 h-4 mr-2" /> - {isCancelling ? '취소 중...' : '응찰 취소'} + {isCancelling ? '취소 중...' : '응찰 포기'} </Button> )} <div className="flex gap-2 ml-auto"> <Button variant="outline" onClick={handleSaveDraft} - disabled={isSavingDraft || isSubmitting || biddingDetail.isFinalSubmission} + disabled={isSavingDraft || isSubmitting || biddingDetail.isFinalSubmission || isExpired} className="min-w-[100px]" > <Save className="w-4 h-4 mr-2" /> @@ -1431,7 +1445,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </Button> <Button onClick={handleSubmitResponse} - disabled={isSubmitting || isSavingDraft || biddingDetail.isFinalSubmission} + disabled={isSubmitting || isSavingDraft || biddingDetail.isFinalSubmission || isExpired} className="min-w-[100px]" > <Send className="w-4 h-4 mr-2" /> |
