From 79cfa7ea8f21ae227dbb2843ae536fe876ba7c55 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 27 Nov 2025 03:08:50 +0000 Subject: (최겸) 구매 입찰 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/basic-contract/service.ts | 2 +- lib/bidding/actions.ts | 139 ++---- lib/bidding/approval-actions.ts | 88 +++- lib/bidding/detail/bidding-actions.ts | 136 +----- lib/bidding/detail/service.ts | 87 +++- lib/bidding/handlers.ts | 22 +- lib/bidding/pre-quote/service.ts | 3 +- lib/bidding/receive/biddings-receive-columns.tsx | 12 +- lib/bidding/receive/biddings-receive-table.tsx | 64 +-- lib/bidding/service.ts | 483 ++++----------------- lib/bidding/vendor/partners-bidding-detail.tsx | 234 +++++----- .../detail/general-contract-basic-info.tsx | 66 ++- .../detail/general-contract-items-table.tsx | 22 +- .../table/add-procurement-items-dialog.tsx | 2 +- lib/procurement-items/validations.ts | 2 +- 15 files changed, 493 insertions(+), 869 deletions(-) (limited to 'lib') diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 6ae3c237..6f4e5d53 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -639,7 +639,7 @@ export async function requestBasicContractInfo({ const host = headersList.get('host') || 'localhost:3000'; // 로그인 또는 서명 페이지 URL 생성 const baseUrl = `http://${host}` - const loginUrl = `${baseUrl}/partners/basic-contract`; + const loginUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/partners/basic-contract`; // 사용자 언어 설정 (기본값은 한국어) const userLang = "ko"; 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 { } } -// 조기개찰 액션 -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`COUNT(*)`.as('participant_expected'), - participantParticipated: sql`COUNT(CASE WHEN invitation_status = 'bidding_submitted' THEN 1 END)`.as('participant_participated'), - participantDeclined: sql`COUNT(CASE WHEN invitation_status IN ('bidding_declined', 'bidding_cancelled') THEN 1 END)`.as('participant_declined'), - participantPending: sql`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`COUNT(*)`.as('participant_expected'), + participantFinalSubmitted: sql`COUNT(CASE WHEN invitation_status = 'bidding_submitted' THEN 1 END)`.as('participant_final_submitted'), + participantDeclined: sql`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 { } } -// 응찰 취소 서버 액션 (최종제출이 아닌 경우만 가능) +// 응찰 포기 서버 액션 (최종제출이 아닌 경우만 가능) 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> { - 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 - - + 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 (
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")} + {new Date(startDate).toISOString().slice(0, 16).replace('T', ' ')} ~ {new Date(endDate).toISOString().slice(0, 16).replace('T', ' ')}
{isActive && ( 진행중 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 | 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} >
- {/* */} )}
- {/* 지불조건 */} + {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */}
- +
+ {/* 지불조건 필드 삭제됨
+ */}
setFormData(prev => ({ - ...prev, - liquidatedDamagesPercent: e.target.value - }))} - disabled={!formData.liquidatedDamages} - /> +
+ + + * 일반적인 계약조건: 지체일수당 계약금액의 0.3%, 최대치 10% + +
+
+ setFormData(prev => ({ + ...prev, + liquidatedDamagesPercent: e.target.value + }))} + disabled={!formData.liquidatedDamages} + /> + % +
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index bda2901e..15e5c926 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -460,7 +460,11 @@ export function ContractItemsTable({ id="batch-unit-price" type="number" value={batchInputData.contractUnitPrice} - onChange={(e) => setBatchInputData(prev => ({ ...prev, contractUnitPrice: e.target.value }))} + onChange={(e) => { + // Leading zero removal + const val = e.target.value.replace(/^0+(?=[0-9])/, '') + setBatchInputData(prev => ({ ...prev, contractUnitPrice: val })) + }} placeholder="계약단가 입력 (선택사항)" />
@@ -507,32 +511,20 @@ export function ContractItemsTable({ {/* 요약 정보 */} -
+ {/*
{isTotalAmountDisabled ? '-' : formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
-
- -
- {formatCurrency(availableBudget, localItems[0]?.contractCurrency || 'KRW')} -
-
-
- -
= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(amountDifference, localItems[0]?.contractCurrency || 'KRW')} -
-
{budgetRatio.toFixed(1)}%
-
+
*/} diff --git a/lib/procurement-items/table/add-procurement-items-dialog.tsx b/lib/procurement-items/table/add-procurement-items-dialog.tsx index b2915dc2..acec40af 100644 --- a/lib/procurement-items/table/add-procurement-items-dialog.tsx +++ b/lib/procurement-items/table/add-procurement-items-dialog.tsx @@ -94,7 +94,7 @@ export function AddProcurementItemDialog({ name="itemCode" render={({ field }) => ( - 품목코드 + 품목코드 * diff --git a/lib/procurement-items/validations.ts b/lib/procurement-items/validations.ts index 1d753e9d..2004049f 100644 --- a/lib/procurement-items/validations.ts +++ b/lib/procurement-items/validations.ts @@ -35,7 +35,7 @@ export const searchParamsCache = createSearchParamsCache({ export type GetProcurementItemsSchema = Awaited> export const createProcurementItemSchema = z.object({ - itemCode: z.string(), + itemCode: z.string().min(1, "품목코드는 필수입니다"), itemName: z.string().min(1, "품목명은 필수입니다"), material: z.string().max(100).optional().or(z.literal("")), specification: z.string().max(255).optional().or(z.literal("")), -- cgit v1.2.3