From ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 7 Nov 2025 08:39:04 +0000 Subject: (최겸) 입찰/견적 수정사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/detail/bidding-actions.ts | 227 +++++++ lib/bidding/detail/service.ts | 160 +++-- .../detail/table/bidding-detail-content.tsx | 201 +++++- .../detail/table/bidding-detail-vendor-columns.tsx | 23 +- .../table/bidding-detail-vendor-create-dialog.tsx | 328 ---------- .../bidding-detail-vendor-toolbar-actions.tsx | 8 +- .../detail/table/bidding-invitation-dialog.tsx | 718 +++++++++++++++------ 7 files changed, 1072 insertions(+), 593 deletions(-) create mode 100644 lib/bidding/detail/bidding-actions.ts delete mode 100644 lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx (limited to 'lib/bidding/detail') diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts new file mode 100644 index 00000000..70bba1c3 --- /dev/null +++ b/lib/bidding/detail/bidding-actions.ts @@ -0,0 +1,227 @@ +'use server' + +import db from '@/db/db' +import { biddings, biddingCompanies, companyPrItemBids } from '@/db/schema/bidding' +import { eq, and } from 'drizzle-orm' +import { revalidateTag, revalidatePath } from 'next/cache' +import { users } from '@/db/schema' + +// userId를 user.name으로 변환하는 유틸리티 함수 +async function getUserNameById(userId: string): Promise { + try { + const user = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, parseInt(userId))) + .limit(1) + + return user[0]?.name || userId + } catch (error) { + console.error('Failed to get user name:', error) + return userId + } +} + +// 응찰 취소 서버 액션 (최종제출이 아닌 경우만 가능) +export async function cancelBiddingResponse( + biddingCompanyId: number, + userId: string +) { + try { + const userName = await getUserNameById(userId) + + return await db.transaction(async (tx) => { + // 1. 현재 상태 확인 (최종제출 여부) + const [company] = await tx + .select({ + isFinalSubmission: biddingCompanies.isFinalSubmission, + biddingId: biddingCompanies.biddingId, + }) + .from(biddingCompanies) + .where(eq(biddingCompanies.id, biddingCompanyId)) + .limit(1) + + if (!company) { + return { + success: false, + error: '업체 정보를 찾을 수 없습니다.' + } + } + + // 최종제출한 경우 취소 불가 + if (company.isFinalSubmission) { + return { + success: false, + error: '최종 제출된 응찰은 취소할 수 없습니다.' + } + } + + // 2. 응찰 데이터 초기화 + await tx + .update(biddingCompanies) + .set({ + finalQuoteAmount: null, + finalQuoteSubmittedAt: null, + isFinalSubmission: false, + invitationStatus: 'bidding_cancelled', // 응찰 취소 상태 + updatedAt: new Date() + }) + .where(eq(biddingCompanies.id, biddingCompanyId)) + + // 3. 품목별 견적 삭제 (본입찰 데이터) + await tx + .delete(companyPrItemBids) + .where( + and( + eq(companyPrItemBids.biddingCompanyId, biddingCompanyId), + eq(companyPrItemBids.isPreQuote, false) + ) + ) + + // 캐시 무효화 + revalidateTag(`bidding-${company.biddingId}`) + revalidateTag('quotation-vendors') + revalidateTag('quotation-details') + revalidatePath(`/partners/bid/${company.biddingId}`) + + return { + success: true, + message: '응찰이 취소되었습니다.' + } + }) + } catch (error) { + console.error('Failed to cancel bidding response:', error) + return { + success: false, + 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 404bc3cd..d58ded8e 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -81,6 +81,7 @@ export interface QuotationVendor { vendorId: number vendorName: string vendorCode: string + vendorEmail?: string // 벤더의 기본 이메일 contactPerson: string contactEmail: string contactPhone: string @@ -90,7 +91,7 @@ export interface QuotationVendor { isWinner: boolean | null // 낙찰여부 (null: 미정, true: 낙찰, false: 탈락) awardRatio: number | null // 발주비율 isBiddingParticipated: boolean | null // 본입찰 참여여부 - status: 'pending' | 'submitted' | 'selected' | 'rejected' + invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted' documents: Array<{ id: number fileName: string @@ -241,6 +242,7 @@ export async function getQuotationVendors(biddingId: number): Promise`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`, awardRatio: biddingCompanies.awardRatio, isBiddingParticipated: biddingCompanies.isBiddingParticipated, - status: sql`CASE - WHEN ${biddingCompanies.isWinner} THEN 'selected' - WHEN ${biddingCompanies.finalQuoteSubmittedAt} IS NOT NULL THEN 'submitted' - WHEN ${biddingCompanies.respondedAt} IS NOT NULL THEN 'submitted' - ELSE 'pending' - END`, + invitationStatus: biddingCompanies.invitationStatus, }) .from(biddingCompanies) .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id)) @@ -272,6 +269,7 @@ export async function getQuotationVendors(biddingId: number): Promise { @@ -630,9 +629,10 @@ export async function createBiddingDetailVendor( const biddingCompanyResult = await tx.insert(biddingCompanies).values({ biddingId: biddingId, companyId: vendorId, - invitationStatus: 'pending', + invitationStatus: 'pending', // 초대 대기 isPreQuoteSelected: true, // 본입찰 등록 기본값 isWinner: null, // 미정 상태로 초기화 0916 + isPriceAdjustmentApplicableQuestion: isPriceAdjustmentApplicableQuestion ?? false, createdAt: new Date(), updatedAt: new Date(), }).returning({ id: biddingCompanies.id }) @@ -730,9 +730,8 @@ export async function markAsDisposal(biddingId: number, userId: string) { itemName: bidding.itemName, biddingType: bidding.biddingType, processedDate: new Date().toLocaleDateString('ko-KR'), - managerName: bidding.managerName, - managerEmail: bidding.managerEmail, - managerPhone: bidding.managerPhone, + bidPicName: bidding.bidPicName, + supplyPicName: bidding.supplyPicName, language: 'ko' } }) @@ -807,7 +806,7 @@ export async function registerBidding(biddingId: number, userId: string) { .update(biddingCompanies) .set({ isBiddingInvited: true, - invitationStatus: 'sent', + invitationStatus: 'bidding_sent', // 입찰 초대 발송 updatedAt: new Date() }) .where(and( @@ -834,9 +833,8 @@ export async function registerBidding(biddingId: number, userId: string) { submissionStartDate: bidding.submissionStartDate, submissionEndDate: bidding.submissionEndDate, biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`, - managerName: bidding.managerName, - managerEmail: bidding.managerEmail, - managerPhone: bidding.managerPhone, + bidPicName: bidding.bidPicName, + supplyPicName: bidding.supplyPicName, language: 'ko' } }) @@ -945,9 +943,8 @@ export async function createRebidding(biddingId: number, userId: string) { submissionStartDate: originalBidding.submissionStartDate, submissionEndDate: originalBidding.submissionEndDate, biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`, - managerName: originalBidding.managerName, - managerEmail: originalBidding.managerEmail, - managerPhone: originalBidding.managerPhone, + bidPicName: originalBidding.bidPicName, + supplyPicName: originalBidding.supplyPicName, language: 'ko' } }) @@ -1521,6 +1518,7 @@ export interface PartnersBiddingListItem { // biddings 정보 biddingId: number biddingNumber: string + originalBiddingNumber: string | null // 원입찰번호 revision: number | null projectName: string itemName: string @@ -1533,9 +1531,10 @@ export interface PartnersBiddingListItem { submissionStartDate: Date | null submissionEndDate: Date | null status: string - managerName: string | null - managerEmail: string | null - managerPhone: string | null + // 입찰담당자 + bidPicName: string | null + // 조달담당자 + supplyPicName: string | null currency: string budget: number | null isUrgent: boolean | null // 긴급여부 @@ -1572,6 +1571,7 @@ export async function getBiddingListForPartners(companyId: number): Promise 0) { - // 협력업체 정보 조회 + // 업체 정보 const companyInfo = await tx .select({ vendorName: vendors.vendorName, @@ -2051,37 +2063,59 @@ export async function updatePartnerAttendance( const companyName = companyInfo.length > 0 ? companyInfo[0].vendorName : '알 수 없음' - // 메일 발송 (템플릿 사용) - try { - const { sendEmail } = await import('@/lib/mail/sendEmail') - - await sendEmail({ - to: biddingInfo[0].managerEmail, - template: 'specification-meeting-attendance', - context: { - biddingNumber: biddingInfo[0].biddingNumber, - title: biddingInfo[0].title, - companyName: companyName, - attendeeCount: attendanceData.attendeeCount, - representativeName: attendanceData.representativeName, - representativePhone: attendanceData.representativePhone, - managerName: biddingInfo[0].managerName, - managerEmail: biddingInfo[0].managerEmail, - currentYear: new Date().getFullYear(), - language: 'ko' - } + // 사양설명회 상세 정보(담당자 email 포함) + const specificationMeetingInfo = await tx + .select({ + contactEmail: specificationMeetings.contactEmail, + meetingDate: specificationMeetings.meetingDate, + meetingTime: specificationMeetings.meetingTime, + location: specificationMeetings.location, }) - - console.log(`사양설명회 참석 알림 메일 발송 완료: ${biddingInfo[0].managerEmail}`) - } catch (emailError) { - console.error('메일 발송 실패:', emailError) - // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리 + .from(specificationMeetings) + .where(eq(specificationMeetings.biddingId, biddingInfo[0].biddingId)) + .limit(1) + + const contactEmail = specificationMeetingInfo.length > 0 ? specificationMeetingInfo[0].contactEmail : null + + // 메일 발송 (템플릿 사용) + if (contactEmail) { + try { + const { sendEmail } = await import('@/lib/mail/sendEmail') + + await sendEmail({ + to: contactEmail, + template: 'specification-meeting-attendance', + context: { + biddingNumber: biddingInfo[0].biddingNumber, + title: biddingInfo[0].title, + companyName: companyName, + attendeeCount: attendanceData.attendeeCount, + representativeName: attendanceData.representativeName, + representativePhone: attendanceData.representativePhone, + bidPicName: biddingInfo[0].bidPicName, + supplyPicName: biddingInfo[0].supplyPicName, + meetingDate: specificationMeetingInfo[0]?.meetingDate, + meetingTime: specificationMeetingInfo[0]?.meetingTime, + location: specificationMeetingInfo[0]?.location, + contactEmail: contactEmail, + currentYear: new Date().getFullYear(), + language: 'ko' + } + }) + + console.log(`사양설명회 참석 알림 메일 발송 완료: ${contactEmail}`) + } catch (emailError) { + console.error('메일 발송 실패:', emailError) + // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리 + } + } else { + console.warn('사양설명회 담당자 이메일이 없습니다.') } - + // 캐시 무효화 revalidateTag(`bidding-${biddingInfo[0].biddingId}`) revalidateTag('quotation-vendors') - + return { ...biddingInfo[0], companyName, diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx index 895016a2..05c7d567 100644 --- a/lib/bidding/detail/table/bidding-detail-content.tsx +++ b/lib/bidding/detail/table/bidding-detail-content.tsx @@ -9,8 +9,17 @@ 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 @@ -27,12 +36,14 @@ export function BiddingDetailContent({ }: BiddingDetailContentProps) { const { toast } = useToast() const [isPending, startTransition] = useTransition() + const session = useSession() const [dialogStates, setDialogStates] = React.useState({ items: false, targetPrice: false, selectionReason: false, - award: false + award: false, + biddingNotice: false }) const [, setRefreshTrigger] = React.useState(0) @@ -42,14 +53,119 @@ export function BiddingDetailContent({ 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 })) }, []) @@ -73,8 +189,91 @@ export function BiddingDetailContent({ }) }, [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 ? ( + 모든 업체 제출 완료 + ) : ( + 제출 대기 중 + )} +
+
+
+ + {/* 개찰 버튼들 */} +
+ + +
+
+
+
+ )} + { - const status = row.original.status - const variant = status === 'selected' ? 'default' : - status === 'submitted' ? 'secondary' : - status === 'rejected' ? 'destructive' : 'outline' + const invitationStatus = row.original.invitationStatus + const variant = invitationStatus === 'bidding_submitted' ? 'default' : + invitationStatus === 'pre_quote_submitted' ? 'secondary' : + invitationStatus === 'bidding_declined' ? 'destructive' : 'outline' - const label = status === 'selected' ? '선정' : - status === 'submitted' ? '견적 제출' : - status === 'rejected' ? '거절' : '대기' + const label = invitationStatus === 'bidding_submitted' ? '응찰 완료' : + invitationStatus === 'pre_quote_submitted' ? '사전견적 제출' : + invitationStatus === 'bidding_declined' ? '응찰 거절' : + invitationStatus === 'pre_quote_declined' ? '사전견적 거절' : + invitationStatus === 'bidding_accepted' ? '응찰 참여' : + invitationStatus === 'pre_quote_accepted' ? '사전견적 참여' : + invitationStatus === 'pending' ? '대기' : + invitationStatus === 'pre_quote_sent' ? '사전견적 초대' : + invitationStatus === 'bidding_sent' ? '응찰 초대' : + invitationStatus || '알 수 없음' return {label} }, diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx deleted file mode 100644 index d0f85b14..00000000 --- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx +++ /dev/null @@ -1,328 +0,0 @@ -'use client' - -import * as React from 'react' -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 { Checkbox } from '@/components/ui/checkbox' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover' -import { Check, ChevronsUpDown, Search, Loader2, X, Plus } from 'lucide-react' -import { cn } from '@/lib/utils' -import { createBiddingDetailVendor } from '@/lib/bidding/detail/service' -import { searchVendorsForBidding } from '@/lib/bidding/service' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { Badge } from '@/components/ui/badge' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' - -interface BiddingDetailVendorCreateDialogProps { - biddingId: number - open: boolean - onOpenChange: (open: boolean) => void - onSuccess: () => void -} - -interface Vendor { - id: number - vendorName: string - vendorCode: string - status: string -} - -export function BiddingDetailVendorCreateDialog({ - biddingId, - open, - onOpenChange, - onSuccess -}: BiddingDetailVendorCreateDialogProps) { - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - - // Vendor 검색 상태 - const [vendorList, setVendorList] = React.useState([]) - const [selectedVendors, setSelectedVendors] = React.useState([]) - const [vendorOpen, setVendorOpen] = React.useState(false) - - // 폼 상태 (간소화 - 필수 항목만) - const [formData, setFormData] = React.useState({ - awardRatio: 100, // 기본 100% - }) - - // 벤더 로드 - const loadVendors = React.useCallback(async () => { - try { - const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드 - setVendorList(result || []) - } catch (error) { - console.error('Failed to load vendors:', error) - toast({ - title: '오류', - description: '벤더 목록을 불러오는데 실패했습니다.', - variant: 'destructive', - }) - setVendorList([]) - } - }, [biddingId]) - - React.useEffect(() => { - if (open) { - loadVendors() - } - }, [open, loadVendors]) - - // 초기화 - React.useEffect(() => { - if (!open) { - setSelectedVendors([]) - setFormData({ - awardRatio: 100, // 기본 100% - }) - } - }, [open]) - - // 벤더 추가 - const handleAddVendor = (vendor: Vendor) => { - if (!selectedVendors.find(v => v.id === vendor.id)) { - setSelectedVendors([...selectedVendors, vendor]) - } - setVendorOpen(false) - } - - // 벤더 제거 - const handleRemoveVendor = (vendorId: number) => { - setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)) - } - - // 이미 선택된 벤더인지 확인 - const isVendorSelected = (vendorId: number) => { - return selectedVendors.some(v => v.id === vendorId) - } - - const handleCreate = () => { - if (selectedVendors.length === 0) { - toast({ - title: '오류', - description: '업체를 선택해주세요.', - variant: 'destructive', - }) - return - } - - startTransition(async () => { - let successCount = 0 - let errorMessages: string[] = [] - - for (const vendor of selectedVendors) { - try { - const response = await createBiddingDetailVendor( - biddingId, - vendor.id - ) - - if (response.success) { - successCount++ - } else { - errorMessages.push(`${vendor.vendorName}: ${response.error}`) - } - } catch (error) { - errorMessages.push(`${vendor.vendorName}: 처리 중 오류가 발생했습니다.`) - } - } - - if (successCount > 0) { - toast({ - title: '성공', - description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`, - }) - onOpenChange(false) - resetForm() - onSuccess() - } - - if (errorMessages.length > 0 && successCount === 0) { - toast({ - title: '오류', - description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`, - variant: 'destructive', - }) - } - }) - } - - const resetForm = () => { - setSelectedVendors([]) - setFormData({ - awardRatio: 100, // 기본 100% - }) - } - - return ( - - - {/* 헤더 */} - - 협력업체 추가 - - 입찰에 참여할 업체를 선택하세요. 여러 개 선택 가능합니다. - - - - {/* 메인 컨텐츠 */} -
-
- {/* 업체 선택 카드 */} - - - 업체 선택 - - 입찰에 참여할 협력업체를 선택하세요. - - - -
- {/* 업체 추가 버튼 */} - - - - - - - - - 검색 결과가 없습니다. - - {vendorList - .filter(vendor => !isVendorSelected(vendor.id)) - .map((vendor) => ( - handleAddVendor(vendor)} - > -
- - {vendor.vendorCode} - - {vendor.vendorName} -
-
- ))} -
-
-
-
-
- - {/* 선택된 업체 목록 */} - {selectedVendors.length > 0 && ( -
-
-

선택된 업체 ({selectedVendors.length}개)

-
-
- {selectedVendors.map((vendor, index) => ( -
-
- - {index + 1}. - - - {vendor.vendorCode} - - - {vendor.vendorName} - -
- -
- ))} -
-
- )} - - {selectedVendors.length === 0 && ( -
-

아직 선택된 업체가 없습니다.

-

위 버튼을 클릭하여 업체를 추가하세요.

-
- )} -
-
-
-
-
- - {/* 푸터 */} - - - - -
-
- ) -} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index e3b5c288..4d987739 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -8,7 +8,7 @@ import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign } from "lu import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" -import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" +import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog" import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog" import { BiddingVendorPricesDialog } from "./bidding-vendor-prices-dialog" import { Bidding } from "@/db/schema" @@ -189,13 +189,11 @@ export function BiddingDetailVendorToolbarActions({ variant="default" size="sm" onClick={handleRegister} - disabled={isPending || bidding.status === 'received_quotation'} + disabled={isPending} > + {/* 입찰등록 시점 재정의 필요*/} 입찰 등록 - {bidding.status === 'received_quotation' && ( - (사전견적 제출 완료) - )}