summaryrefslogtreecommitdiff
path: root/lib/bidding
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-27 03:08:50 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-27 03:08:50 +0000
commit79cfa7ea8f21ae227dbb2843ae536fe876ba7c55 (patch)
treef12efae72c62286c1a2e9a3f31d695ca22d83b6e /lib/bidding
parente1da84ac863989b9f63b089c09aaa2bbcdc3d6cd (diff)
(최겸) 구매 입찰 수정
Diffstat (limited to 'lib/bidding')
-rw-r--r--lib/bidding/actions.ts139
-rw-r--r--lib/bidding/approval-actions.ts88
-rw-r--r--lib/bidding/detail/bidding-actions.ts136
-rw-r--r--lib/bidding/detail/service.ts87
-rw-r--r--lib/bidding/handlers.ts22
-rw-r--r--lib/bidding/pre-quote/service.ts3
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx12
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx64
-rw-r--r--lib/bidding/service.ts483
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx234
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" />