From 8642ee064ddf96f1db2b948b4cc8bbbd6cfee820 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 12 Nov 2025 10:42:36 +0000 Subject: (최겸) 구매 일반계약, 입찰 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/actions.ts | 230 ++++++++++++- lib/bidding/detail/service.ts | 120 +++++-- .../detail/table/bidding-detail-content.tsx | 314 ------------------ .../detail/table/bidding-detail-items-dialog.tsx | 12 +- .../table/bidding-detail-target-price-dialog.tsx | 356 --------------------- .../detail/table/bidding-detail-vendor-columns.tsx | 18 +- .../detail/table/bidding-detail-vendor-table.tsx | 53 ++- .../bidding-detail-vendor-toolbar-actions.tsx | 222 +++++-------- .../detail/table/bidding-vendor-prices-dialog.tsx | 297 ----------------- .../detail/table/quotation-history-dialog.tsx | 254 +++++++++++++++ lib/bidding/failure/biddings-closure-dialog.tsx | 142 ++++++++ lib/bidding/failure/biddings-failure-columns.tsx | 130 +++++--- lib/bidding/failure/biddings-failure-table.tsx | 266 +++++++++++++-- lib/bidding/list/bidding-detail-dialogs.tsx | 122 ------- lib/bidding/list/biddings-table-columns.tsx | 13 +- lib/bidding/list/biddings-table.tsx | 21 +- lib/bidding/list/create-bidding-dialog.tsx | 2 - lib/bidding/receive/biddings-receive-columns.tsx | 86 ++--- lib/bidding/receive/biddings-receive-table.tsx | 149 +++++++-- lib/bidding/selection/actions.ts | 219 +++++++++++++ lib/bidding/selection/bidding-info-card.tsx | 96 ++++++ .../selection/bidding-selection-detail-content.tsx | 41 +++ .../selection/biddings-selection-columns.tsx | 4 - lib/bidding/selection/biddings-selection-table.tsx | 4 - lib/bidding/selection/selection-result-form.tsx | 143 +++++++++ lib/bidding/selection/vendor-selection-table.tsx | 66 ++++ lib/bidding/service.ts | 96 +++++- lib/bidding/vendor/partners-bidding-detail.tsx | 6 +- .../detail/general-contract-info-header.tsx | 3 +- .../main/create-general-contract-dialog.tsx | 3 +- .../main/general-contract-update-sheet.tsx | 3 +- .../main/general-contracts-table-columns.tsx | 2 + .../main/general-contracts-table.tsx | 3 +- lib/general-contracts/service.ts | 3 +- lib/general-contracts/types.ts | 3 +- lib/users/auth/passwordUtil.ts | 83 ++++- 36 files changed, 2104 insertions(+), 1481 deletions(-) delete mode 100644 lib/bidding/detail/table/bidding-detail-content.tsx delete mode 100644 lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx delete mode 100644 lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx create mode 100644 lib/bidding/detail/table/quotation-history-dialog.tsx create mode 100644 lib/bidding/failure/biddings-closure-dialog.tsx create mode 100644 lib/bidding/selection/actions.ts create mode 100644 lib/bidding/selection/bidding-info-card.tsx create mode 100644 lib/bidding/selection/bidding-selection-detail-content.tsx create mode 100644 lib/bidding/selection/selection-result-form.tsx create mode 100644 lib/bidding/selection/vendor-selection-table.tsx (limited to 'lib') diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts index b5736707..d0c7a0cd 100644 --- a/lib/bidding/actions.ts +++ b/lib/bidding/actions.ts @@ -1,7 +1,9 @@ "use server" import db from "@/db/db" -import { eq, and } from "drizzle-orm" +import { eq, and, sql } from "drizzle-orm" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { biddings, biddingCompanies, @@ -484,8 +486,15 @@ export async function bidClosureAction( description: string files: File[] }, - userId: string + userId: string | undefined ) { + if (!userId) { + return { + success: false, + error: '사용자 정보가 필요합니다.' + } + } + try { const userName = await getUserNameById(userId) @@ -573,6 +582,62 @@ export async function bidClosureAction( } } +// 유찰취소 액션 +export async function cancelDisposalAction( + biddingId: number, + userId: string +) { + try { + const userName = await getUserNameById(userId) + + return await db.transaction(async (tx) => { + // 1. 입찰 정보 확인 + const [existingBidding] = await tx + .select() + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + if (!existingBidding) { + return { + success: false, + error: '입찰 정보를 찾을 수 없습니다.' + } + } + + // 2. 유찰 또는 폐찰 상태인지 확인 + if (existingBidding.status !== 'bidding_disposal' && existingBidding.status !== 'bid_closure') { + return { + success: false, + error: '유찰 또는 폐찰 상태인 입찰만 취소할 수 있습니다.' + } + } + + // 3. 입찰 상태를 입찰 진행중으로 변경 + await tx + .update(biddings) + .set({ + status: 'evaluation_of_bidding', + updatedAt: new Date(), + updatedBy: userName, + }) + .where(eq(biddings.id, biddingId)) + + return { + success: true, + message: '유찰 취소가 완료되었습니다.' + } + }) + + } catch (error) { + console.error('유찰취소 실패:', error) + return { + success: false, + error: error instanceof Error ? error.message : '유찰취소 중 오류가 발생했습니다.' + } + } +} + // 사용자 이름 조회 헬퍼 함수 async function getUserNameById(userId: string): Promise { try { @@ -588,3 +653,164 @@ async function getUserNameById(userId: string): Promise { return userId } } + +// 조기개찰 액션 +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) + 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. 입찰평가중 상태로 변경 + 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 : '개찰 중 오류가 발생했습니다.' + } + } +} diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index d58ded8e..b5a3cce8 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -1,13 +1,14 @@ 'use server' import db from '@/db/db' -import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users } from '@/db/schema' -import { specificationMeetings } from '@/db/schema/bidding' +import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, priceAdjustmentForms, users, vendorContacts } from '@/db/schema' +import { specificationMeetings, biddingCompaniesContacts } from '@/db/schema/bidding' import { eq, and, sql, desc, ne } from 'drizzle-orm' import { revalidatePath, revalidateTag } from 'next/cache' import { unstable_cache } from "@/lib/unstable-cache"; import { sendEmail } from '@/lib/mail/sendEmail' import { saveFile } from '@/lib/file-stroage' +import { sendBiddingNoticeSms } from '@/lib/users/auth/passwordUtil' // userId를 user.name으로 변환하는 유틸리티 함수 async function getUserNameById(userId: string): Promise { @@ -205,28 +206,20 @@ export async function getBiddingCompaniesData(biddingId: number) { } } -// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 적용) +// prItemsForBidding 테이블에서 품목 정보 조회 (캐시 미적용, always fresh) export async function getPRItemsForBidding(biddingId: number) { - return unstable_cache( - async () => { - try { - const items = await db - .select() - .from(prItemsForBidding) - .where(eq(prItemsForBidding.biddingId, biddingId)) - .orderBy(prItemsForBidding.id) + try { + const items = await db + .select() + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + .orderBy(prItemsForBidding.id) - return items - } catch (error) { - console.error('Failed to get PR items for bidding:', error) - return [] - } - }, - [`pr-items-for-bidding-${biddingId}`], - { - tags: [`bidding-${biddingId}`, 'pr-items'] - } - )() + return items + } catch (error) { + console.error('Failed to get PR items for bidding:', error) + return [] + } } // 견적 시스템에서 협력업체 정보를 가져오는 함수 (캐시 적용) @@ -757,10 +750,10 @@ export async function markAsDisposal(biddingId: number, userId: string) { } } -// 입찰 등록 (사전견적에서 선정된 업체들에게 본입찰 초대 발송) +// 입찰 등록 ( 본입찰 초대 발송) export async function registerBidding(biddingId: number, userId: string) { try { - // 사전견적에서 선정된 업체들 + 본입찰에서 개별적으로 추가한 업체들 조회 + // 본입찰에서 개별적으로 추가한 업체들 조회 const selectedCompanies = await db .select({ companyId: biddingCompanies.companyId, @@ -769,10 +762,9 @@ export async function registerBidding(biddingId: number, userId: string) { }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.isPreQuoteSelected, true) - )) + .where( + eq(biddingCompanies.biddingId, biddingId) + ) // 입찰 정보 조회 const biddingInfo = await db @@ -843,7 +835,37 @@ export async function registerBidding(biddingId: number, userId: string) { } } } + // 4. 입찰 공고 SMS 알림 전송 + for (const company of selectedCompanies) { + // biddingCompaniesContacts에서 모든 연락처 전화번호 조회 + const contactInfos = await db + .select({ + contactNumber: biddingCompaniesContacts.contactNumber + }) + .from(biddingCompaniesContacts) + .where(and( + eq(biddingCompaniesContacts.biddingId, biddingId), + eq(biddingCompaniesContacts.vendorId, company.companyId) + )); + + // 각 연락처에 SMS 전송 + for (const contactInfo of contactInfos) { + const contactPhone = contactInfo.contactNumber; + if (contactPhone) { + try { + const smsResult = await sendBiddingNoticeSms(contactPhone, bidding.title); + if (smsResult.success) { + console.log(`입찰 공고 SMS 전송 성공: ${contactPhone} - ${bidding.title}`); + } else { + console.error(`입찰 공고 SMS 전송 실패: ${contactPhone} - ${smsResult.error}`); + } + } catch (smsError) { + console.error(`Failed to send bidding notice SMS to ${contactPhone}:`, smsError) + } + } + } + } // 캐시 무효화 revalidateTag(`bidding-${biddingId}`) revalidateTag('bidding-detail') @@ -1352,7 +1374,7 @@ export async function updateBiddingParticipation( return { success: true, - message: `입찰 참여상태가 ${participated ? '응찰' : '미응찰'}로 업데이트되었습니다.`, + message: `입찰 참여상태가 ${participated ? '응찰' :'응찰포기'}로 업데이트되었습니다.`, } } catch (error) { console.error('Failed to update bidding participation:', error) @@ -1483,7 +1505,7 @@ export async function updatePartnerBiddingParticipation( return { success: true, - message: `입찰 참여상태가 ${participated ? '응찰' : '미응찰'}로 업데이트되었습니다.`, + message: `입찰 참여상태가 ${participated ? '응찰' : '응찰포기'}로 업데이트되었습니다.`, } } catch (error) { console.error('Failed to update partner bidding participation:', error) @@ -1802,8 +1824,6 @@ export async function submitPartnerResponse( } } - - // 3. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우) // if (response.priceAdjustmentResponse && response.priceAdjustmentForm) { // const priceAdjustmentData = { @@ -1854,8 +1874,8 @@ export async function submitPartnerResponse( if (response.finalQuoteAmount !== undefined) { companyUpdateData.finalQuoteAmount = response.finalQuoteAmount companyUpdateData.finalQuoteSubmittedAt = new Date() - - // 최종제출 여부에 따라 상태 및 플래그 설정 + + // isFinalSubmission에 따라 상태 및 플래그 설정 if (response.isFinalSubmission) { companyUpdateData.isFinalSubmission = true companyUpdateData.invitationStatus = 'bidding_submitted' // 응찰 완료 @@ -1863,6 +1883,38 @@ export async function submitPartnerResponse( companyUpdateData.isFinalSubmission = false // 임시저장: invitationStatus는 변경하지 않음 (bidding_accepted 유지) } + + // 스냅샷은 임시저장/최종제출 관계없이 항상 생성 + if (response.prItemQuotations && response.prItemQuotations.length > 0) { + // 기존 스냅샷 조회 + const existingCompany = await tx + .select({ quotationSnapshots: biddingCompanies.quotationSnapshots }) + .from(biddingCompanies) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .limit(1) + + const existingSnapshots = existingCompany[0]?.quotationSnapshots as any[] || [] + + // 새로운 스냅샷 생성 + const newSnapshot = { + id: Date.now().toString(), // 고유 ID + round: existingSnapshots.length + 1, // 차수 + submittedAt: new Date().toISOString(), + totalAmount: response.finalQuoteAmount, + currency: 'KRW', + isFinalSubmission: !!response.isFinalSubmission, + items: response.prItemQuotations.map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice, + bidAmount: item.bidAmount, + proposedDeliveryDate: item.proposedDeliveryDate, + technicalSpecification: item.technicalSpecification + })) + } + + // 스냅샷 배열에 추가 + companyUpdateData.quotationSnapshots = [...existingSnapshots, newSnapshot] + } } await tx diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx deleted file mode 100644 index 05c7d567..00000000 --- a/lib/bidding/detail/table/bidding-detail-content.tsx +++ /dev/null @@ -1,314 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bidding } from '@/db/schema' -import { QuotationDetails, QuotationVendor } from '@/lib/bidding/detail/service' - -import { BiddingDetailVendorTableContent } from './bidding-detail-vendor-table' -import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog' -import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog' -import { BiddingPreQuoteItemDetailsDialog } from '../../../bidding/pre-quote/table/bidding-pre-quote-item-details-dialog' -import { getPrItemsForBidding } from '../../../bidding/pre-quote/service' -import { checkAllVendorsFinalSubmitted, performBidOpening } from '../bidding-actions' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { useSession } from 'next-auth/react' -import { BiddingNoticeEditor } from '@/lib/bidding/bidding-notice-editor' -import { getBiddingNotice } from '@/lib/bidding/service' -import { Button } from '@/components/ui/button' -import { Card, CardContent } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' -import { FileText, Eye, CheckCircle2, AlertCircle } from 'lucide-react' - -interface BiddingDetailContentProps { - bidding: Bidding - quotationDetails: QuotationDetails | null - quotationVendors: QuotationVendor[] - prItems: any[] -} - -export function BiddingDetailContent({ - bidding, - quotationDetails, - quotationVendors, - prItems -}: BiddingDetailContentProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const session = useSession() - - const [dialogStates, setDialogStates] = React.useState({ - items: false, - targetPrice: false, - selectionReason: false, - award: false, - biddingNotice: false - }) - - const [, setRefreshTrigger] = React.useState(0) - - // PR 아이템 다이얼로그 관련 state - const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false) - const [selectedVendorForDetails, setSelectedVendorForDetails] = React.useState(null) - const [prItemsForDialog, setPrItemsForDialog] = React.useState([]) - - // 입찰공고 관련 state - const [biddingNotice, setBiddingNotice] = React.useState(null) - const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false) - - // 최종제출 현황 관련 state - const [finalSubmissionStatus, setFinalSubmissionStatus] = React.useState<{ - allSubmitted: boolean - totalCompanies: number - submittedCompanies: number - }>({ allSubmitted: false, totalCompanies: 0, submittedCompanies: 0 }) - const [isPerformingBidOpening, setIsPerformingBidOpening] = React.useState(false) - - const handleRefresh = React.useCallback(() => { - setRefreshTrigger(prev => prev + 1) - }, []) - - // 입찰공고 로드 함수 - const loadBiddingNotice = React.useCallback(async () => { - if (!bidding.id) return - - setIsBiddingNoticeLoading(true) - try { - const notice = await getBiddingNotice(bidding.id) - setBiddingNotice(notice) - } catch (error) { - console.error('Failed to load bidding notice:', error) - toast({ - title: '오류', - description: '입찰공고문을 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsBiddingNoticeLoading(false) - } - }, [bidding.id, toast]) - - const openDialog = React.useCallback((type: keyof typeof dialogStates) => { - setDialogStates(prev => ({ ...prev, [type]: true })) - }, []) - - // 최종제출 현황 로드 함수 - const loadFinalSubmissionStatus = React.useCallback(async () => { - if (!bidding.id) return - - try { - const status = await checkAllVendorsFinalSubmitted(bidding.id) - setFinalSubmissionStatus(status) - } catch (error) { - console.error('Failed to load final submission status:', error) - } - }, [bidding.id]) - - // 개찰 핸들러 - const handlePerformBidOpening = async (isEarly: boolean = false) => { - if (!session.data?.user?.id) { - toast({ - title: '권한 없음', - description: '로그인이 필요합니다.', - variant: 'destructive', - }) - return - } - - if (!finalSubmissionStatus.allSubmitted) { - toast({ - title: '개찰 불가', - description: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${finalSubmissionStatus.submittedCompanies}/${finalSubmissionStatus.totalCompanies})`, - variant: 'destructive', - }) - return - } - - const message = isEarly ? '조기개찰을 진행하시겠습니까?' : '개찰을 진행하시겠습니까?' - if (!window.confirm(message)) { - return - } - - setIsPerformingBidOpening(true) - try { - const result = await performBidOpening(bidding.id, session.data.user.id.toString(), isEarly) - - if (result.success) { - toast({ - title: '개찰 완료', - description: result.message, - }) - // 페이지 새로고침 - window.location.reload() - } else { - toast({ - title: '개찰 실패', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - console.error('Failed to perform bid opening:', error) - toast({ - title: '오류', - description: '개찰에 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsPerformingBidOpening(false) - } - } - - // 컴포넌트 마운트 시 입찰공고 및 최종제출 현황 로드 - React.useEffect(() => { - loadBiddingNotice() - loadFinalSubmissionStatus() - }, [loadBiddingNotice, loadFinalSubmissionStatus]) - - const closeDialog = React.useCallback((type: keyof typeof dialogStates) => { - setDialogStates(prev => ({ ...prev, [type]: false })) - }, []) - - const handleViewItemDetails = React.useCallback((vendor: QuotationVendor) => { - startTransition(async () => { - try { - // PR 아이템 정보 로드 - const prItemsData = await getPrItemsForBidding(bidding.id) - setPrItemsForDialog(prItemsData) - setSelectedVendorForDetails(vendor) - setIsItemDetailsDialogOpen(true) - } catch (error) { - console.error('Failed to load PR items:', error) - toast({ - title: '오류', - description: '품목 정보를 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } - }) - }, [bidding.id, toast]) - - // 개찰 버튼 표시 여부 (입찰평가중 상태에서만) - const showBidOpeningButtons = bidding.status === 'evaluation_of_bidding' - - return ( -
- {/* 입찰공고 편집 버튼 */} -
-
-

입찰 상세

-

{bidding.title}

-
- setDialogStates(prev => ({ ...prev, biddingNotice: open }))}> - - - - - - 입찰공고 편집 - -
- setDialogStates(prev => ({ ...prev, biddingNotice: false }))} - /> -
-
-
-
- - {/* 최종제출 현황 및 개찰 버튼 */} - {showBidOpeningButtons && ( - - -
-
-
-
- {finalSubmissionStatus.allSubmitted ? ( - - ) : ( - - )} -

최종제출 현황

-
-
- - 최종 제출: {finalSubmissionStatus.submittedCompanies}/{finalSubmissionStatus.totalCompanies}개 업체 - - {finalSubmissionStatus.allSubmitted ? ( - 모든 업체 제출 완료 - ) : ( - 제출 대기 중 - )} -
-
-
- - {/* 개찰 버튼들 */} -
- - -
-
-
-
- )} - - openDialog('targetPrice')} - onOpenSelectionReasonDialog={() => openDialog('selectionReason')} - onViewItemDetails={handleViewItemDetails} - onEdit={undefined} - /> - - closeDialog('items')} - prItems={prItems} - bidding={bidding} - /> - - closeDialog('targetPrice')} - quotationDetails={quotationDetails} - bidding={bidding} - onSuccess={handleRefresh} - /> - - -
- ) -} diff --git a/lib/bidding/detail/table/bidding-detail-items-dialog.tsx b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx index 8c2ae44a..086ab67d 100644 --- a/lib/bidding/detail/table/bidding-detail-items-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-items-dialog.tsx @@ -25,12 +25,12 @@ interface PrItem { itemName: string itemCode: string specification: string - quantity: number - unit: string - estimatedPrice: number - budget: number - deliveryDate: Date - notes: string + quantity: number | null + unit: string | null + estimatedPrice: number | null + budget: number | null + deliveryDate: Date | null + notes: string | null createdAt: Date updatedAt: Date } diff --git a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx b/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx deleted file mode 100644 index a8f604d8..00000000 --- a/lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx +++ /dev/null @@ -1,356 +0,0 @@ -'use client' - -import * as React from 'react' -import { Bidding } from '@/db/schema' -import { QuotationDetails, updateTargetPrice, calculateAndUpdateTargetPrice, getPreQuoteData } from '@/lib/bidding/detail/service' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' - -interface BiddingDetailTargetPriceDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - quotationDetails: QuotationDetails | null - bidding: Bidding - onSuccess: () => void -} - -export function BiddingDetailTargetPriceDialog({ - open, - onOpenChange, - quotationDetails, - bidding, - onSuccess -}: BiddingDetailTargetPriceDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const [targetPrice, setTargetPrice] = React.useState( - bidding.targetPrice ? Number(bidding.targetPrice) : 0 - ) - const [calculationCriteria, setCalculationCriteria] = React.useState( - (bidding as any).targetPriceCalculationCriteria || '' - ) - const [preQuoteData, setPreQuoteData] = React.useState(null) - const [isAutoCalculating, setIsAutoCalculating] = React.useState(false) - - // Dialog가 열릴 때 상태 초기화 및 사전견적 데이터 로드 - React.useEffect(() => { - if (open) { - setTargetPrice(bidding.targetPrice ? Number(bidding.targetPrice) : 0) - setCalculationCriteria((bidding as any).targetPriceCalculationCriteria || '') - - // 사전견적 데이터 로드 - const loadPreQuoteData = async () => { - try { - const data = await getPreQuoteData(bidding.id) - setPreQuoteData(data) - } catch (error) { - console.error('Failed to load pre-quote data:', error) - } - } - loadPreQuoteData() - } - }, [open, bidding]) - - // 자동 산정 함수 - const handleAutoCalculate = () => { - setIsAutoCalculating(true) - - startTransition(async () => { - try { - const result = await calculateAndUpdateTargetPrice( - bidding.id - ) - - if (result.success && result.data) { - setTargetPrice(result.data.targetPrice) - setCalculationCriteria(result.data.criteria) - setPreQuoteData(result.data.preQuoteData) - - toast({ - title: '성공', - description: result.message, - }) - - onSuccess() - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - toast({ - title: '오류', - description: '내정가 자동 산정에 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsAutoCalculating(false) - } - }) - } - - const handleSave = () => { - // 필수값 검증 - if (targetPrice <= 0) { - toast({ - title: '유효성 오류', - description: '내정가는 0보다 큰 값을 입력해주세요.', - variant: 'destructive', - }) - return - } - - if (!calculationCriteria.trim()) { - toast({ - title: '유효성 오류', - description: '내정가 산정 기준을 입력해주세요.', - variant: 'destructive', - }) - return - } - - startTransition(async () => { - const result = await updateTargetPrice( - bidding.id, - targetPrice, - calculationCriteria.trim() - ) - - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - onSuccess() - onOpenChange(false) - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: bidding.currency || 'KRW', - }).format(amount) - } - - return ( - - - - 내정가 산정 - - 입찰번호: {bidding.biddingNumber} - 견적 통계 및 내정가 설정 - - - -
- {/* 사전견적 리스트 */} - {preQuoteData?.quotes && preQuoteData.quotes.length > 0 && ( -
-

사전견적 현황

-
- - - - 업체명 - 사전견적가 - 제출일 - - - - {preQuoteData.quotes.map((quote: any) => ( - - - {quote.vendorName || `업체 ${quote.companyId}`} - - - {formatCurrency(Number(quote.preQuoteAmount))} - - - {quote.submittedAt - ? new Date(quote.submittedAt).toLocaleDateString('ko-KR') - : '-' - } - - - ))} - -
-
-
- )} - - - - - 항목 - - - - - {/* 사전견적 통계 정보 */} - - 사전견적 수 - - {preQuoteData?.quotationCount || 0}개 - - - {preQuoteData?.lowestQuote && ( - - 최저 사전견적가 - - {formatCurrency(preQuoteData.lowestQuote)} - - - )} - {preQuoteData?.highestQuote && ( - - 최고 사전견적가 - - {formatCurrency(preQuoteData.highestQuote)} - - - )} - {preQuoteData?.averageQuote && ( - - 평균 사전견적가 - - {formatCurrency(preQuoteData.averageQuote)} - - - )} - - {/* 입찰 유형 */} - - 입찰 유형 - - {bidding.biddingType || '-'} - - - - {/* 예산 정보 */} - {bidding.budget && ( - - 예산 - - {formatCurrency(Number(bidding.budget))} - - - )} - - {/* 최종 업데이트 시간 */} - {quotationDetails?.lastUpdated && ( - - 최종 업데이트 - - {new Date(quotationDetails.lastUpdated).toLocaleString('ko-KR')} - - - )} - - {/* 내정가 입력 */} - - - - - -
-
- setTargetPrice(Number(e.target.value))} - placeholder="내정가를 입력하세요" - className="flex-1" - /> - -
-
- {targetPrice > 0 ? formatCurrency(targetPrice) : ''} -
- {preQuoteData?.quotationCount === 0 && ( -
- 사전견적 데이터가 없어 자동 산정이 불가능합니다. -
- )} -
-
-
- - {/* 내정가 산정 기준 입력 */} - - - - - -