diff options
Diffstat (limited to 'lib/bidding')
28 files changed, 2015 insertions, 1467 deletions
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<string> { try { @@ -588,3 +653,164 @@ async function getUserNameById(userId: string): Promise<string> { 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<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) + 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<string> { @@ -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<QuotationVendor | null>(null) - const [prItemsForDialog, setPrItemsForDialog] = React.useState<any[]>([]) - - // 입찰공고 관련 state - const [biddingNotice, setBiddingNotice] = React.useState<any>(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 ( - <div className="space-y-6"> - {/* 입찰공고 편집 버튼 */} - <div className="flex justify-between items-center"> - <div> - <h2 className="text-2xl font-bold">입찰 상세</h2> - <p className="text-muted-foreground">{bidding.title}</p> - </div> - <Dialog open={dialogStates.biddingNotice} onOpenChange={(open) => setDialogStates(prev => ({ ...prev, biddingNotice: open }))}> - <DialogTrigger asChild> - <Button variant="outline" className="gap-2"> - <FileText className="h-4 w-4" /> - 입찰공고 편집 - </Button> - </DialogTrigger> - <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden"> - <DialogHeader> - <DialogTitle>입찰공고 편집</DialogTitle> - </DialogHeader> - <div className="max-h-[60vh] overflow-y-auto"> - <BiddingNoticeEditor - initialData={biddingNotice} - biddingId={bidding.id} - onSaveSuccess={() => setDialogStates(prev => ({ ...prev, biddingNotice: false }))} - /> - </div> - </DialogContent> - </Dialog> - </div> - - {/* 최종제출 현황 및 개찰 버튼 */} - {showBidOpeningButtons && ( - <Card> - <CardContent className="pt-6"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4"> - <div> - <div className="flex items-center gap-2 mb-1"> - {finalSubmissionStatus.allSubmitted ? ( - <CheckCircle2 className="h-5 w-5 text-green-600" /> - ) : ( - <AlertCircle className="h-5 w-5 text-yellow-600" /> - )} - <h3 className="text-lg font-semibold">최종제출 현황</h3> - </div> - <div className="flex items-center gap-2"> - <span className="text-sm text-muted-foreground"> - 최종 제출: {finalSubmissionStatus.submittedCompanies}/{finalSubmissionStatus.totalCompanies}개 업체 - </span> - {finalSubmissionStatus.allSubmitted ? ( - <Badge variant="default">모든 업체 제출 완료</Badge> - ) : ( - <Badge variant="secondary">제출 대기 중</Badge> - )} - </div> - </div> - </div> - - {/* 개찰 버튼들 */} - <div className="flex gap-2"> - <Button - onClick={() => handlePerformBidOpening(false)} - disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening} - variant="default" - > - <Eye className="h-4 w-4 mr-2" /> - {isPerformingBidOpening ? '처리 중...' : '개찰'} - </Button> - <Button - onClick={() => handlePerformBidOpening(true)} - disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening} - variant="outline" - > - <Eye className="h-4 w-4 mr-2" /> - {isPerformingBidOpening ? '처리 중...' : '조기개찰'} - </Button> - </div> - </div> - </CardContent> - </Card> - )} - - <BiddingDetailVendorTableContent - biddingId={bidding.id} - bidding={bidding} - vendors={quotationVendors} - onRefresh={handleRefresh} - onOpenTargetPriceDialog={() => openDialog('targetPrice')} - onOpenSelectionReasonDialog={() => openDialog('selectionReason')} - onViewItemDetails={handleViewItemDetails} - onEdit={undefined} - /> - - <BiddingDetailItemsDialog - open={dialogStates.items} - onOpenChange={(open) => closeDialog('items')} - prItems={prItems} - bidding={bidding} - /> - - <BiddingDetailTargetPriceDialog - open={dialogStates.targetPrice} - onOpenChange={(open) => closeDialog('targetPrice')} - quotationDetails={quotationDetails} - bidding={bidding} - onSuccess={handleRefresh} - /> - - <BiddingPreQuoteItemDetailsDialog - open={isItemDetailsDialogOpen} - onOpenChange={setIsItemDetailsDialogOpen} - biddingId={bidding.id} - biddingCompanyId={selectedVendorForDetails?.id || 0} - companyName={selectedVendorForDetails?.vendorName || ''} - prItems={prItemsForDialog} - currency={bidding.currency || 'KRW'} - /> - </div> - ) -} 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<any>(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 ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[800px]"> - <DialogHeader> - <DialogTitle>내정가 산정</DialogTitle> - <DialogDescription> - 입찰번호: {bidding.biddingNumber} - 견적 통계 및 내정가 설정 - </DialogDescription> - </DialogHeader> - - <div className="space-y-4"> - {/* 사전견적 리스트 */} - {preQuoteData?.quotes && preQuoteData.quotes.length > 0 && ( - <div className="mb-4"> - <h4 className="text-sm font-medium mb-2">사전견적 현황</h4> - <div className="border rounded-lg"> - <Table> - <TableHeader> - <TableRow> - <TableHead>업체명</TableHead> - <TableHead className="text-right">사전견적가</TableHead> - <TableHead className="text-right">제출일</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {preQuoteData.quotes.map((quote: any) => ( - <TableRow key={quote.id}> - <TableCell className="font-medium"> - {quote.vendorName || `업체 ${quote.companyId}`} - </TableCell> - <TableCell className="text-right font-mono"> - {formatCurrency(Number(quote.preQuoteAmount))} - </TableCell> - <TableCell className="text-right text-sm text-muted-foreground"> - {quote.submittedAt - ? new Date(quote.submittedAt).toLocaleDateString('ko-KR') - : '-' - } - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - </div> - )} - - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[200px]">항목</TableHead> - <TableHead>값</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {/* 사전견적 통계 정보 */} - <TableRow> - <TableCell className="font-medium">사전견적 수</TableCell> - <TableCell className="font-semibold"> - {preQuoteData?.quotationCount || 0}개 - </TableCell> - </TableRow> - {preQuoteData?.lowestQuote && ( - <TableRow> - <TableCell className="font-medium">최저 사전견적가</TableCell> - <TableCell className="font-semibold text-green-600"> - {formatCurrency(preQuoteData.lowestQuote)} - </TableCell> - </TableRow> - )} - {preQuoteData?.highestQuote && ( - <TableRow> - <TableCell className="font-medium">최고 사전견적가</TableCell> - <TableCell className="font-semibold text-blue-600"> - {formatCurrency(preQuoteData.highestQuote)} - </TableCell> - </TableRow> - )} - {preQuoteData?.averageQuote && ( - <TableRow> - <TableCell className="font-medium">평균 사전견적가</TableCell> - <TableCell className="font-semibold"> - {formatCurrency(preQuoteData.averageQuote)} - </TableCell> - </TableRow> - )} - - {/* 입찰 유형 */} - <TableRow> - <TableCell className="font-medium">입찰 유형</TableCell> - <TableCell className="font-semibold"> - {bidding.biddingType || '-'} - </TableCell> - </TableRow> - - {/* 예산 정보 */} - {bidding.budget && ( - <TableRow> - <TableCell className="font-medium">예산</TableCell> - <TableCell className="font-semibold"> - {formatCurrency(Number(bidding.budget))} - </TableCell> - </TableRow> - )} - - {/* 최종 업데이트 시간 */} - {quotationDetails?.lastUpdated && ( - <TableRow> - <TableCell className="font-medium">최종 업데이트</TableCell> - <TableCell className="text-sm text-muted-foreground"> - {new Date(quotationDetails.lastUpdated).toLocaleString('ko-KR')} - </TableCell> - </TableRow> - )} - - {/* 내정가 입력 */} - <TableRow> - <TableCell className="font-medium"> - <Label htmlFor="targetPrice" className="text-sm font-medium"> - 내정가 * - </Label> - </TableCell> - <TableCell> - <div className="space-y-2"> - <div className="flex gap-2"> - <Input - id="targetPrice" - type="number" - value={targetPrice} - onChange={(e) => setTargetPrice(Number(e.target.value))} - placeholder="내정가를 입력하세요" - className="flex-1" - /> - <Button - type="button" - variant="outline" - onClick={handleAutoCalculate} - disabled={isAutoCalculating || isPending || !preQuoteData?.quotationCount} - className="whitespace-nowrap" - > - {isAutoCalculating ? '산정 중...' : '자동 산정'} - </Button> - </div> - <div className="text-sm text-muted-foreground"> - {targetPrice > 0 ? formatCurrency(targetPrice) : ''} - </div> - {preQuoteData?.quotationCount === 0 && ( - <div className="text-xs text-orange-600"> - 사전견적 데이터가 없어 자동 산정이 불가능합니다. - </div> - )} - </div> - </TableCell> - </TableRow> - - {/* 내정가 산정 기준 입력 */} - <TableRow> - <TableCell className="font-medium align-top pt-2"> - <Label htmlFor="calculationCriteria" className="text-sm font-medium"> - 내정가 산정 기준 * - </Label> - </TableCell> - <TableCell> - <Textarea - id="calculationCriteria" - value={calculationCriteria} - onChange={(e) => setCalculationCriteria(e.target.value)} - placeholder="내정가 산정 기준을 자세히 입력해주세요. 자동 산정 시 입찰유형에 따른 기준이 자동 설정됩니다." - className="w-full min-h-[100px]" - rows={4} - /> - <div className="text-xs text-muted-foreground mt-1"> - 필수 입력 사항입니다. 내정가 산정에 대한 근거를 명확히 기재해주세요. - </div> - </TableCell> - </TableRow> - </TableBody> - </Table> - </div> - - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 취소 - </Button> - <Button onClick={handleSave} disabled={isPending || isAutoCalculating}> - 저장 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx index 10085e55..af7d70e1 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx @@ -24,6 +24,7 @@ interface GetVendorColumnsProps { onViewItemDetails?: (vendor: QuotationVendor) => void onSendBidding?: (vendor: QuotationVendor) => void onUpdateParticipation?: (vendor: QuotationVendor, participated: boolean) => void + onViewQuotationHistory?: (vendor: QuotationVendor) => void biddingStatus?: string // 입찰 상태 정보 추가 } @@ -32,6 +33,7 @@ export function getBiddingDetailVendorColumns({ onViewItemDetails, onSendBidding, onUpdateParticipation, + onViewQuotationHistory, biddingStatus }: GetVendorColumnsProps): ColumnDef<QuotationVendor>[] { return [ @@ -124,7 +126,7 @@ export function getBiddingDetailVendorColumns({ } return ( <Badge variant={participated ? 'default' : 'destructive'}> - {participated ? '응찰' : '미응찰'} + {participated ? '응찰' : '응찰포기'} </Badge> ) }, @@ -198,7 +200,7 @@ export function getBiddingDetailVendorColumns({ 응찰 설정 </DropdownMenuItem> <DropdownMenuItem onClick={() => onUpdateParticipation(vendor, false)}> - 미응찰 설정 + 응찰포기 설정 </DropdownMenuItem> </> )} @@ -212,7 +214,17 @@ export function getBiddingDetailVendorColumns({ </DropdownMenuItem> </> )} - + + {/* 견적 히스토리 (응찰한 업체만) */} + {vendor.isBiddingParticipated === true && onViewQuotationHistory && ( + <> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => onViewQuotationHistory(vendor)}> + 견적 히스토리 + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> </DropdownMenu> ) diff --git a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx index f2b05d4e..315c2aac 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-table.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-table.tsx @@ -13,6 +13,7 @@ import { getBiddingDetailVendorColumns } from './bidding-detail-vendor-columns' import { QuotationVendor, getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service' import { Bidding } from '@/db/schema' import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' +import { QuotationHistoryDialog } from './quotation-history-dialog' import { useToast } from '@/hooks/use-toast' interface BiddingDetailVendorTableContentProps { @@ -20,10 +21,10 @@ interface BiddingDetailVendorTableContentProps { bidding: Bidding vendors: QuotationVendor[] onRefresh: () => void - onOpenTargetPriceDialog: () => void onOpenSelectionReasonDialog: () => void onEdit?: (vendor: QuotationVendor) => void onViewItemDetails?: (vendor: QuotationVendor) => void + onViewQuotationHistory?: (vendor: QuotationVendor) => void } const filterFields: DataTableFilterField<QuotationVendor>[] = [ @@ -82,9 +83,9 @@ export function BiddingDetailVendorTableContent({ bidding, vendors, onRefresh, - onOpenTargetPriceDialog, onEdit, - onViewItemDetails + onViewItemDetails, + onViewQuotationHistory }: BiddingDetailVendorTableContentProps) { const { data: session } = useSession() const { toast } = useToast() @@ -96,6 +97,8 @@ export function BiddingDetailVendorTableContent({ const [isAwardDialogOpen, setIsAwardDialogOpen] = React.useState(false) const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null) const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false) + const [quotationHistoryData, setQuotationHistoryData] = React.useState<any>(null) + const [isQuotationHistoryDialogOpen, setIsQuotationHistoryDialogOpen] = React.useState(false) const handleEdit = (vendor: QuotationVendor) => { setSelectedVendor(vendor) @@ -126,14 +129,46 @@ export function BiddingDetailVendorTableContent({ } } + const handleViewQuotationHistory = async (vendor: QuotationVendor) => { + try { + const { getQuotationHistory } = await import('@/lib/bidding/selection/actions') + const result = await getQuotationHistory(biddingId, vendor.vendorId) + + if (result.success) { + setQuotationHistoryData({ + vendorName: vendor.vendorName, + history: result.data.history, + biddingCurrency: bidding.currency || 'KRW', + targetPrice: bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : undefined + }) + setSelectedVendor(vendor) + setIsQuotationHistoryDialogOpen(true) + } else { + toast({ + title: '오류', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + console.error('Failed to load quotation history:', error) + toast({ + title: '오류', + description: '견적 히스토리를 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } + } + const columns = React.useMemo( () => getBiddingDetailVendorColumns({ onEdit: onEdit || handleEdit, onViewPriceAdjustment: handleViewPriceAdjustment, onViewItemDetails: onViewItemDetails, + onViewQuotationHistory: onViewQuotationHistory || handleViewQuotationHistory, biddingStatus: bidding.status }), - [onEdit, handleEdit, handleViewPriceAdjustment, onViewItemDetails, bidding.status] + [onEdit, handleEdit, handleViewPriceAdjustment, onViewItemDetails, onViewQuotationHistory, handleViewQuotationHistory, bidding.status] ) const { table } = useDataTable({ @@ -163,7 +198,6 @@ export function BiddingDetailVendorTableContent({ biddingId={biddingId} bidding={bidding} userId={userId} - onOpenTargetPriceDialog={onOpenTargetPriceDialog} onOpenAwardDialog={() => setIsAwardDialogOpen(true)} onSuccess={onRefresh} /> @@ -192,6 +226,15 @@ export function BiddingDetailVendorTableContent({ data={priceAdjustmentData} vendorName={selectedVendor?.vendorName || ''} /> + + <QuotationHistoryDialog + open={isQuotationHistoryDialogOpen} + onOpenChange={setIsQuotationHistoryDialogOpen} + vendorName={quotationHistoryData?.vendorName || ''} + history={quotationHistoryData?.history || []} + biddingCurrency={quotationHistoryData?.biddingCurrency || 'KRW'} + targetPrice={quotationHistoryData?.targetPrice} + /> </> ) } 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 4d987739..e3db8861 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -4,22 +4,20 @@ import * as React from "react" import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" -import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign } from "lucide-react" +import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign, RotateCw } from "lucide-react" import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service" import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service" +import { increaseRoundOrRebid } from "@/lib/bidding/service" 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" import { useToast } from "@/hooks/use-toast" -import { BiddingInvitationDialog } from "./bidding-invitation-dialog" interface BiddingDetailVendorToolbarActionsProps { biddingId: number bidding: Bidding userId: string - onOpenTargetPriceDialog: () => void onOpenAwardDialog: () => void onSuccess: () => void } @@ -28,7 +26,6 @@ export function BiddingDetailVendorToolbarActions({ biddingId, bidding, userId, - onOpenTargetPriceDialog, onOpenAwardDialog, onSuccess }: BiddingDetailVendorToolbarActionsProps) { @@ -75,52 +72,52 @@ export function BiddingDetailVendorToolbarActions({ setIsBiddingInvitationDialogOpen(true) } - const handleBiddingInvitationSend = async (data: any) => { - try { - // 1. 기본계약 발송 - const contractResult = await sendBiddingBasicContracts( - biddingId, - data.vendors, - data.generatedPdfs, - data.message - ) - - if (!contractResult.success) { - toast({ - title: '기본계약 발송 실패', - description: contractResult.error, - variant: 'destructive', - }) - return - } - - // 2. 입찰 등록 진행 - const registerResult = await registerBidding(bidding.id, userId) - - if (registerResult.success) { - toast({ - title: '본입찰 초대 완료', - description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.', - }) - setIsBiddingInvitationDialogOpen(false) - router.refresh() - onSuccess() - } else { - toast({ - title: '오류', - description: registerResult.error, - variant: 'destructive', - }) - } - } catch (error) { - console.error('본입찰 초대 실패:', error) - toast({ - title: '오류', - description: '본입찰 초대에 실패했습니다.', - variant: 'destructive', - }) - } - } + // const handleBiddingInvitationSend = async (data: any) => { + // try { + // // 1. 기본계약 발송 + // const contractResult = await sendBiddingBasicContracts( + // biddingId, + // data.vendors, + // data.generatedPdfs, + // data.message + // ) + + // if (!contractResult.success) { + // toast({ + // title: '기본계약 발송 실패', + // description: contractResult.error, + // variant: 'destructive', + // }) + // return + // } + + // // 2. 입찰 등록 진행 + // const registerResult = await registerBidding(bidding.id, userId) + + // if (registerResult.success) { + // toast({ + // title: '본입찰 초대 완료', + // description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.', + // }) + // setIsBiddingInvitationDialogOpen(false) + // router.refresh() + // onSuccess() + // } else { + // toast({ + // title: '오류', + // description: registerResult.error, + // variant: 'destructive', + // }) + // } + // } catch (error) { + // console.error('본입찰 초대 실패:', error) + // toast({ + // title: '오류', + // description: '본입찰 초대에 실패했습니다.', + // variant: 'destructive', + // }) + // } + // } // 선정된 업체들 조회 (서버 액션 함수 사용) const getSelectedVendors = async () => { @@ -158,21 +155,21 @@ export function BiddingDetailVendorToolbarActions({ }) } - const handleCreateRebidding = () => { + const handleRoundIncrease = () => { startTransition(async () => { - const result = await createRebidding(bidding.id, userId) + const result = await increaseRoundOrRebid(bidding.id, userId, 'round_increase') if (result.success) { toast({ - title: result.message, + title: "성공", description: result.message, }) router.refresh() onSuccess() } else { toast({ - title: result.error, - description: result.error, + title: "오류", + description: result.error || "차수증가 중 오류가 발생했습니다.", variant: 'destructive', }) } @@ -183,80 +180,47 @@ export function BiddingDetailVendorToolbarActions({ <> <div className="flex items-center gap-2"> {/* 상태별 액션 버튼 */} - {bidding.status !== 'bidding_closed' && bidding.status !== 'vendor_selected' && ( - <> - <Button - variant="default" - size="sm" - onClick={handleRegister} - disabled={isPending} - > - {/* 입찰등록 시점 재정의 필요*/} - <Send className="mr-2 h-4 w-4" /> - 입찰 등록 - </Button> - <Button - variant="destructive" - size="sm" - onClick={handleMarkAsDisposal} - disabled={isPending} - > - <XCircle className="mr-2 h-4 w-4" /> - 유찰 - </Button> - <Button - variant="default" - size="sm" - onClick={onOpenAwardDialog} - disabled={isPending} - > - <Trophy className="mr-2 h-4 w-4" /> - 낙찰 - </Button> - - - {bidding.status === 'bidding_disposal' && ( + {/* 차수증가: 입찰공고 또는 입찰 진행중 상태 */} + {(bidding.status === 'bidding_generated' || bidding.status === 'bidding_opened') && ( <Button variant="outline" size="sm" - onClick={handleCreateRebidding} + onClick={handleRoundIncrease} disabled={isPending} > - <RotateCcw className="mr-2 h-4 w-4" /> - 재입찰 + <RotateCw className="mr-2 h-4 w-4" /> + 차수증가 </Button> )} - {/* 구분선 */} - {(bidding.status === 'bidding_generated' || - bidding.status === 'bidding_disposal') && ( - <div className="h-4 w-px bg-border mx-1" /> - )} - - {/* 공통 관리 버튼들 */} - {/* <Button - variant="outline" - size="sm" - onClick={onOpenItemsDialog} - > - 품목 정보 - </Button> */} - + {/* 유찰/낙찰: 입찰 진행중 상태에서만 */} + {bidding.status === 'bidding_opened' && ( + <> <Button - variant="outline" + variant="destructive" size="sm" - onClick={onOpenTargetPriceDialog} + onClick={handleMarkAsDisposal} + disabled={isPending} > - 내정가 산정 + <XCircle className="mr-2 h-4 w-4" /> + 유찰 </Button> <Button - variant="outline" + variant="default" size="sm" - onClick={handleCreateVendor} + onClick={onOpenAwardDialog} + disabled={isPending} > - <Plus className="mr-2 h-4 w-4" /> - 업체 추가 + <Trophy className="mr-2 h-4 w-4" /> + 낙찰 </Button> + </> + )} + {/* 구분선 */} + {(bidding.status === 'bidding_generated' || + bidding.status === 'bidding_disposal') && ( + <div className="h-4 w-px bg-border mx-1" /> + )} <Button variant="outline" size="sm" @@ -265,16 +229,7 @@ export function BiddingDetailVendorToolbarActions({ <FileText className="mr-2 h-4 w-4" /> 입찰문서 등록 </Button> - <Button - variant="outline" - size="sm" - onClick={handleViewVendorPrices} - > - <DollarSign className="mr-2 h-4 w-4" /> - 입찰가 비교 - </Button> - </> - )} + </div> <BiddingDetailVendorCreateDialog @@ -295,25 +250,6 @@ export function BiddingDetailVendorToolbarActions({ onSuccess={onSuccess} /> - <BiddingVendorPricesDialog - open={isPricesDialogOpen} - onOpenChange={setIsPricesDialogOpen} - biddingId={biddingId} - biddingTitle={bidding.title} - budget={bidding.budget ? parseFloat(bidding.budget.toString()) : null} - targetPrice={bidding.targetPrice ? parseFloat(bidding.targetPrice.toString()) : null} - currency={bidding.currency || ''} - /> - - <BiddingInvitationDialog - open={isBiddingInvitationDialogOpen} - onOpenChange={setIsBiddingInvitationDialogOpen} - vendors={selectedVendors} - biddingId={biddingId} - biddingTitle={bidding.title || ''} - projectName={bidding.projectName || ''} - onSend={handleBiddingInvitationSend} - /> </> ) } diff --git a/lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx b/lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx deleted file mode 100644 index dfcef812..00000000 --- a/lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx +++ /dev/null @@ -1,297 +0,0 @@ -'use client' - -import * as React from 'react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { Badge } from '@/components/ui/badge' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - DollarSign, - Building, - TrendingDown, - TrendingUp -} from 'lucide-react' -import { useToast } from '@/hooks/use-toast' -import { getVendorPricesForBidding } from '../service' - -interface VendorPrice { - companyId: number - companyName: string - biddingCompanyId: number - totalAmount: number - currency: string - itemPrices: Array<{ - prItemId: number - itemName: string - quantity: number - quantityUnit: string - unitPrice: number - amount: number - proposedDeliveryDate?: string - }> -} - -interface BiddingVendorPricesDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - biddingId: number - biddingTitle: string - budget?: number | null - targetPrice?: number | null - currency?: string -} - -export function BiddingVendorPricesDialog({ - open, - onOpenChange, - biddingId, - biddingTitle, - budget, - targetPrice, - currency = 'KRW' -}: BiddingVendorPricesDialogProps) { - const { toast } = useToast() - const [vendorPrices, setVendorPrices] = React.useState<VendorPrice[]>([]) - const [isLoading, setIsLoading] = React.useState(false) - - const loadVendorPrices = React.useCallback(async () => { - setIsLoading(true) - try { - const data = await getVendorPricesForBidding(biddingId) - setVendorPrices(data) - } catch (error) { - console.error('Failed to load vendor prices:', error) - toast({ - title: '오류', - description: '입찰가 정보를 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsLoading(false) - } - }, [biddingId, toast]) - - // 다이얼로그가 열릴 때 데이터 로드 - React.useEffect(() => { - if (open) { - loadVendorPrices() - } - }, [open, loadVendorPrices]) - - - // 금액 포맷팅 - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: currency, - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(amount) - } - - // 수량 포맷팅 - const formatQuantity = (quantity: number, unit: string) => { - return `${quantity.toLocaleString()} ${unit}` - } - - // 최저가 계산 - const getLowestPrice = (itemPrices: VendorPrice['itemPrices']) => { - const validPrices = itemPrices.filter(item => item.quantity > 0) - - if (validPrices.length === 0) return null - - const prices = validPrices.map(item => item.unitPrice) - return Math.min(...prices) - } - - // 최고가 계산 - const getHighestPrice = (itemPrices: VendorPrice['itemPrices']) => { - const validPrices = itemPrices.filter(item => item.quantity > 0) - - if (validPrices.length === 0) return null - - const prices = validPrices.map(item => item.unitPrice) - return Math.max(...prices) - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <DollarSign className="w-6 h-6" /> - <span>입찰가 비교 분석</span> - <Badge variant="outline" className="ml-auto"> - {biddingTitle} - </Badge> - </DialogTitle> - <DialogDescription> - 협력업체별 품목별 입찰가 정보를 비교하여 최적의 낙찰 대상을 선정할 수 있습니다. - </DialogDescription> - </DialogHeader> - - <div className="space-y-6"> - {/* 상단 요약 정보 */} - <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-sm font-medium flex items-center gap-2"> - <DollarSign className="w-4 h-4" /> - 예산 - </CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-blue-600"> - {budget ? formatCurrency(budget) : '-'} - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-sm font-medium flex items-center gap-2"> - <TrendingDown className="w-4 h-4" /> - 내정가 - </CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-green-600"> - {targetPrice ? formatCurrency(targetPrice) : '-'} - </div> - </CardContent> - </Card> - - <Card> - <CardHeader className="pb-3"> - <CardTitle className="text-sm font-medium flex items-center gap-2"> - <Building className="w-4 h-4" /> - 참여 업체 수 - </CardTitle> - </CardHeader> - <CardContent> - <div className="text-2xl font-bold text-purple-600"> - {vendorPrices.length}개사 - </div> - </CardContent> - </Card> - </div> - - - {isLoading ? ( - <div className="flex items-center justify-center py-12"> - <div className="text-center"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> - <p className="text-muted-foreground">입찰가 정보를 불러오는 중...</p> - </div> - </div> - ) : vendorPrices.length > 0 ? ( - <div className="space-y-4"> - {vendorPrices.map((vendor) => ( - <Card key={vendor.companyId}> - <CardHeader> - <CardTitle className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <Building className="w-5 h-5" /> - <span>{vendor.companyName}</span> - </div> - <div className="text-right"> - <div className="text-lg font-bold text-green-600"> - {formatCurrency(vendor.totalAmount)} - </div> - <div className="text-xs text-muted-foreground"> - 총 입찰금액 - </div> - </div> - </CardTitle> - </CardHeader> - <CardContent> - <Table> - <TableHeader> - <TableRow> - <TableHead>품목명</TableHead> - <TableHead className="text-right">수량</TableHead> - <TableHead className="text-right">단가</TableHead> - <TableHead className="text-right">금액</TableHead> - <TableHead className="text-center">가격대</TableHead> - <TableHead>납기일</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {vendor.itemPrices - .filter(item => item.quantity > 0) - .map((item, index) => { - const lowestPrice = getLowestPrice(vendor.itemPrices) - const highestPrice = getHighestPrice(vendor.itemPrices) - const isLowest = item.unitPrice === lowestPrice - const isHighest = item.unitPrice === highestPrice - - return ( - <TableRow key={`${item.prItemId}-${index}`}> - <TableCell className="font-medium"> - {item.itemName} - </TableCell> - <TableCell className="text-right font-mono"> - {formatQuantity(item.quantity, item.quantityUnit)} - </TableCell> - <TableCell className="text-right font-mono"> - {formatCurrency(item.unitPrice)} - </TableCell> - <TableCell className="text-right font-mono"> - {formatCurrency(item.amount)} - </TableCell> - <TableCell className="text-center"> - <div className="flex justify-center"> - {isLowest && ( - <Badge variant="destructive" className="text-xs"> - <TrendingDown className="w-3 h-3 mr-1" /> - 최저 - </Badge> - )} - {isHighest && ( - <Badge variant="secondary" className="text-xs"> - <TrendingUp className="w-3 h-3 mr-1" /> - 최고 - </Badge> - )} - </div> - </TableCell> - <TableCell> - {item.proposedDeliveryDate ? - new Date(item.proposedDeliveryDate).toLocaleDateString('ko-KR') : - '-' - } - </TableCell> - </TableRow> - ) - })} - </TableBody> - </Table> - </CardContent> - </Card> - ))} - </div> - ) : ( - <div className="text-center py-12 text-gray-500"> - <DollarSign className="w-12 h-12 mx-auto mb-4 opacity-50" /> - <p className="text-lg font-medium mb-2">입찰가 정보가 없습니다</p> - <p className="text-sm">협력업체들이 아직 입찰가를 제출하지 않았습니다.</p> - </div> - )} - </div> - </DialogContent> - </Dialog> - ) -} diff --git a/lib/bidding/detail/table/quotation-history-dialog.tsx b/lib/bidding/detail/table/quotation-history-dialog.tsx new file mode 100644 index 00000000..b816368a --- /dev/null +++ b/lib/bidding/detail/table/quotation-history-dialog.tsx @@ -0,0 +1,254 @@ +'use client' + +import * as React from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { formatDate } from '@/lib/utils' +import { History, Eye } from 'lucide-react' + +interface QuotationHistoryItem { + id: string + round: number + submittedAt: Date + totalAmount: number + currency: string + vsTargetPrice: number // 퍼센트 + items: Array<{ + itemCode: string + itemName: string + specification: string + quantity: number + unit: string + unitPrice: number + totalPrice: number + deliveryDate: Date + }> +} + +interface QuotationHistoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorName: string + history: QuotationHistoryItem[] + biddingCurrency: string + targetPrice?: number +} + +export function QuotationHistoryDialog({ + open, + onOpenChange, + vendorName, + history, + biddingCurrency, + targetPrice +}: QuotationHistoryDialogProps) { + const [selectedHistory, setSelectedHistory] = React.useState<QuotationHistoryItem | null>(null) + const [detailDialogOpen, setDetailDialogOpen] = React.useState(false) + + const handleViewDetail = (historyItem: QuotationHistoryItem) => { + setSelectedHistory(historyItem) + setDetailDialogOpen(true) + } + + const handleDetailDialogClose = () => { + setDetailDialogOpen(false) + setSelectedHistory(null) + } + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <History className="h-5 w-5" /> + 견적 히스토리 - {vendorName} + </DialogTitle> + <DialogDescription> + {vendorName} 업체의 제출한 견적 내역을 확인할 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {history.length > 0 ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead>차수</TableHead> + <TableHead>제출일시</TableHead> + <TableHead className="text-right">견적금액</TableHead> + <TableHead className="text-right">내정가대비 (%)</TableHead> + <TableHead className="text-center">액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {history.map((item) => ( + <TableRow key={item.id}> + <TableCell className="font-medium"> + {item.round}차 + </TableCell> + <TableCell> + {formatDate(item.submittedAt, 'KR')} + </TableCell> + <TableCell className="text-right font-mono"> + {item.totalAmount.toLocaleString()} {item.currency} + </TableCell> + <TableCell className="text-right"> + {targetPrice && targetPrice > 0 ? ( + <Badge + variant={item.vsTargetPrice <= 0 ? 'default' : 'destructive'} + > + {item.vsTargetPrice > 0 ? '+' : ''}{item.vsTargetPrice.toFixed(1)}% + </Badge> + ) : ( + '-' + )} + </TableCell> + <TableCell className="text-center"> + <Button + variant="outline" + size="sm" + onClick={() => handleViewDetail(item)} + > + <Eye className="h-4 w-4 mr-1" /> + 상세 + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + 제출된 견적 내역이 없습니다. + </div> + )} + </div> + </DialogContent> + </Dialog> + + {/* 상세 다이얼로그 */} + {selectedHistory && ( + <QuotationHistoryDetailDialog + open={detailDialogOpen} + onOpenChange={handleDetailDialogClose} + vendorName={vendorName} + historyItem={selectedHistory} + /> + )} + </> + ) +} + +// 견적 히스토리 상세 다이얼로그 +interface QuotationHistoryDetailDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorName: string + historyItem: QuotationHistoryItem +} + +function QuotationHistoryDetailDialog({ + open, + onOpenChange, + vendorName, + historyItem +}: QuotationHistoryDetailDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle> + 견적 상세 - {vendorName} ({historyItem.round}차) + </DialogTitle> + <DialogDescription> + 제출일시: {formatDate(historyItem.submittedAt, 'KR')} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 요약 정보 */} + <div className="grid grid-cols-3 gap-4 p-4 bg-muted/50 rounded-lg"> + <div> + <label className="text-sm font-medium text-muted-foreground">총 견적금액</label> + <div className="text-lg font-bold"> + {historyItem.totalAmount.toLocaleString()} {historyItem.currency} + </div> + </div> + <div> + <label className="text-sm font-medium text-muted-foreground">품목 수</label> + <div className="text-lg font-bold"> + {historyItem.items.length}개 + </div> + </div> + <div> + <label className="text-sm font-medium text-muted-foreground">제출일시</label> + <div className="text-sm"> + {formatDate(historyItem.submittedAt, 'KR')} + </div> + </div> + </div> + + {/* 품목 상세 테이블 */} + <Table> + <TableHeader> + <TableRow> + <TableHead>품목코드</TableHead> + <TableHead>품목명</TableHead> + <TableHead>규격</TableHead> + <TableHead className="text-right">수량</TableHead> + <TableHead>단위</TableHead> + <TableHead className="text-right">단가</TableHead> + <TableHead className="text-right">금액</TableHead> + <TableHead>납기요청일</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {historyItem.items.map((item, index) => ( + <TableRow key={index}> + <TableCell className="font-mono text-sm"> + {item.itemCode} + </TableCell> + <TableCell className="font-medium"> + {item.itemName} + </TableCell> + <TableCell className="text-sm"> + {item.specification || '-'} + </TableCell> + <TableCell className="text-right"> + {item.quantity.toLocaleString()} + </TableCell> + <TableCell>{item.unit}</TableCell> + <TableCell className="text-right font-mono"> + {item.unitPrice.toLocaleString()} {historyItem.currency} + </TableCell> + <TableCell className="text-right font-mono"> + {item.totalPrice.toLocaleString()} {historyItem.currency} + </TableCell> + <TableCell className="text-sm"> + {formatDate(item.deliveryDate, 'KR')} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/failure/biddings-closure-dialog.tsx b/lib/bidding/failure/biddings-closure-dialog.tsx new file mode 100644 index 00000000..64aba42f --- /dev/null +++ b/lib/bidding/failure/biddings-closure-dialog.tsx @@ -0,0 +1,142 @@ +// 폐찰하기 다이얼로그 +"use client" + +import { useState } from "react" +import { toast } from "sonner" +import { bidClosureAction } from "@/lib/bidding/actions" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { FileXIcon } from "lucide-react" + +interface BiddingsClosureDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bidding: { + id: number; + title: string; + biddingNumber: string; + } | null; + userId: string; + onSuccess?: () => void; + } + + export function BiddingsClosureDialog({ + open, + onOpenChange, + bidding, + userId, + onSuccess + }: BiddingsClosureDialogProps) { + const [description, setDescription] = useState('') + const [files, setFiles] = useState<File[]>([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!bidding || !description.trim()) { + toast.error('폐찰 사유를 입력해주세요.') + return + } + + setIsSubmitting(true) + + try { + const result = await bidClosureAction(bidding.id, { + description: description.trim(), + files + }, userId) + + if (result.success) { + toast.success(result.message) + onOpenChange(false) + onSuccess?.() + // 페이지 새로고침 또는 상태 업데이트 + window.location.reload() + } else { + toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.') + } + } catch (error) { + toast.error('폐찰 처리 중 오류가 발생했습니다.') + } finally { + setIsSubmitting(false) + } + } + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + if (e.target.files) { + setFiles(Array.from(e.target.files)) + } + } + + if (!bidding) return null + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileXIcon className="h-5 w-5 text-destructive" /> + 폐찰하기 + </DialogTitle> + <DialogDescription> + {bidding.title} ({bidding.biddingNumber})를 폐찰합니다. + </DialogDescription> + </DialogHeader> + + <form onSubmit={handleSubmit} className="space-y-4"> + <div className="space-y-2"> + <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label> + <Textarea + id="description" + placeholder="폐찰 사유를 입력해주세요..." + value={description} + onChange={(e) => setDescription(e.target.value)} + className="min-h-[100px]" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="files">첨부파일</Label> + <Input + id="files" + type="file" + multiple + onChange={handleFileChange} + className="cursor-pointer" + accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png" + /> + {files.length > 0 && ( + <div className="text-sm text-muted-foreground"> + 선택된 파일: {files.map(f => f.name).join(', ')} + </div> + )} + </div> + + <div className="flex justify-end gap-2 pt-4"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + variant="destructive" + disabled={isSubmitting || !description.trim()} + > + {isSubmitting ? '처리 중...' : '폐찰하기'} + </Button> + </div> + </form> + </DialogContent> + </Dialog> + ) + } +
\ No newline at end of file diff --git a/lib/bidding/failure/biddings-failure-columns.tsx b/lib/bidding/failure/biddings-failure-columns.tsx index 8a888079..3046dbc0 100644 --- a/lib/bidding/failure/biddings-failure-columns.tsx +++ b/lib/bidding/failure/biddings-failure-columns.tsx @@ -5,8 +5,9 @@ import { type ColumnDef } from "@tanstack/react-table" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
- Eye, Calendar, FileX, DollarSign, AlertTriangle, RefreshCw
+ Eye, Calendar, FileX, DollarSign, AlertTriangle, RefreshCw, FileText
} from "lucide-react"
+import { Checkbox } from "@/components/ui/checkbox"
import {
Tooltip,
TooltipContent,
@@ -27,6 +28,7 @@ import { } from "@/db/schema"
import { formatDate } from "@/lib/utils"
import { DataTableRowAction } from "@/types/table"
+import { downloadFile } from "@/lib/file-download"
type BiddingFailureItem = {
id: number
@@ -55,6 +57,15 @@ type BiddingFailureItem = { disposalUpdatedAt: Date | null // 폐찰수정일
disposalUpdatedBy: string | null // 폐찰수정자
+ // 폐찰 정보
+ closureReason: string | null // 폐찰사유
+ closureDocuments: {
+ id: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ }[] // 폐찰 첨부파일들
+
// 기타 정보
createdBy: string | null
createdAt: Date | null
@@ -94,6 +105,25 @@ const formatCurrency = (amount: string | number | null, currency = 'KRW') => { export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingFailureItem>[] {
return [
+ // ░░░ 선택 ░░░
+ {
+ id: "select",
+ header: "",
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // single select 모드에서는 다른 행들의 선택을 해제
+ row.toggleSelected(!!value)
+ }}
+ aria-label="행 선택"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
// ░░░ 입찰번호 ░░░
{
accessorKey: "biddingNumber",
@@ -188,7 +218,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co accessorKey: "biddingRegistrationDate",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />,
cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.biddingRegistrationDate, "KR")}</span>
+ <span className="text-sm">{row.original.biddingRegistrationDate ? formatDate(row.original.biddingRegistrationDate, "KR") : '-'}</span>
),
size: 100,
meta: { excelHeader: "입찰등록일" },
@@ -216,7 +246,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co cell: ({ row }) => (
<div className="flex items-center gap-1">
<AlertTriangle className="h-4 w-4 text-red-500" />
- <span className="text-sm">{formatDate(row.original.disposalDate, "KR")}</span>
+ <span className="text-sm">{row.original.disposalDate ? formatDate(row.original.disposalDate, "KR") : '-'}</span>
</div>
),
size: 100,
@@ -230,7 +260,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co cell: ({ row }) => (
<div className="flex items-center gap-1">
<FileX className="h-4 w-4 text-red-500" />
- <span className="text-sm">{formatDate(row.original.disposalUpdatedAt, "KR")}</span>
+ <span className="text-sm">{row.original.disposalUpdatedAt ? formatDate(row.original.disposalUpdatedAt, "KR") : '-'}</span>
</div>
),
size: 100,
@@ -248,6 +278,57 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co meta: { excelHeader: "폐찰수정자" },
},
+ // ░░░ 폐찰사유 ░░░
+ {
+ id: "closureReason",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰사유" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.closureReason || undefined}>
+ <span className="text-sm">{row.original.closureReason || '-'}</span>
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "폐찰사유" },
+ },
+
+ // ░░░ 폐찰첨부파일 ░░░
+ {
+ id: "closureDocuments",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰첨부파일" />,
+ cell: ({ row }) => {
+ const documents = row.original.closureDocuments || []
+
+ if (documents.length === 0) {
+ return <span className="text-sm text-muted-foreground">-</span>
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1 max-w-[200px]">
+ {documents.map((doc) => (
+ <Button
+ key={doc.id}
+ variant="link"
+ size="sm"
+ className="p-0 h-auto text-xs underline"
+ onClick={async () => {
+ try {
+ await downloadFile(doc.filePath, doc.originalFileName)
+ } catch (error) {
+ console.error('파일 다운로드 실패:', error)
+ }
+ }}
+ >
+ <FileText className="mr-1 h-3 w-3" />
+ {doc.originalFileName}
+ </Button>
+ ))}
+ </div>
+ )
+ },
+ size: 200,
+ meta: { excelHeader: "폐찰첨부파일" },
+ },
+
// ░░░ P/R번호 ░░░
{
accessorKey: "prNumber",
@@ -267,7 +348,7 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co <span className="text-sm">{row.original.createdBy || '-'}</span>
),
size: 100,
- meta: { excelHeader: "등록자" },
+ meta: { excelHeader: "최종수정자" },
},
// ░░░ 등록일시 ░░░
@@ -275,46 +356,11 @@ export function getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): Co accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.createdAt, "KR")}</span>
+ <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
),
size: 100,
- meta: { excelHeader: "등록일시" },
+ meta: { excelHeader: "최종일시" },
},
- // ═══════════════════════════════════════════════════════════════
- // 액션
- // ═══════════════════════════════════════════════════════════════
- {
- id: "actions",
- header: "액션",
- cell: ({ row }) => (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">메뉴 열기</span>
- <FileX className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- <Eye className="mr-2 h-4 w-4" />
- 상세보기
- </DropdownMenuItem>
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "history" })}>
- <Calendar className="mr-2 h-4 w-4" />
- 이력보기
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "rebid" })}>
- <RefreshCw className="mr-2 h-4 w-4" />
- 재입찰
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
]
}
diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx index 901648d2..c80021ea 100644 --- a/lib/bidding/failure/biddings-failure-table.tsx +++ b/lib/bidding/failure/biddings-failure-table.tsx @@ -18,7 +18,12 @@ import { biddingStatusLabels,
contractTypeLabels,
} from "@/db/schema"
-import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { BiddingsClosureDialog } from "./biddings-closure-dialog"
+import { Button } from "@/components/ui/button"
+import { FileX, RefreshCw, Undo2 } from "lucide-react"
+import { bidClosureAction, cancelDisposalAction } from "@/lib/bidding/actions"
+import { increaseRoundOrRebid } from "@/lib/bidding/service"
+import { useToast } from "@/hooks/use-toast"
type BiddingFailureItem = {
id: number
@@ -30,7 +35,7 @@ type BiddingFailureItem = { prNumber: string | null
// 가격 정보
- targetPrice: number | null
+ targetPrice: string | number | null
currency: string | null
// 일정 정보
@@ -47,6 +52,15 @@ type BiddingFailureItem = { disposalUpdatedAt: Date | null // 폐찰수정일
disposalUpdatedBy: string | null // 폐찰수정자
+ // 폐찰 정보
+ closureReason: string | null // 폐찰사유
+ closureDocuments: {
+ id: number
+ fileName: string
+ originalFileName: string
+ filePath: string
+ }[] // 폐찰 첨부파일들
+
// 기타 정보
createdBy: string | null
createdAt: Date | null
@@ -69,9 +83,9 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { const { data, pageCount } = biddingsResult
const [isCompact, setIsCompact] = React.useState<boolean>(false)
- const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [biddingClosureDialogOpen, setBiddingClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
+ const { toast } = useToast()
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | null>(null)
@@ -89,17 +103,18 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { setSelectedBidding(rowAction.row.original)
switch (rowAction.type) {
- case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ case "rebid":
+ // 재입찰
+ handleRebid(rowAction.row.original)
break
- case "history":
- // 이력보기 (추후 구현)
- console.log('이력보기:', rowAction.row.original)
+ case "closure":
+ // 폐찰
+ setSelectedBidding(rowAction.row.original)
+ setBiddingClosureDialogOpen(true)
break
- case "rebid":
- // 재입찰 (추후 구현)
- console.log('재입찰:', rowAction.row.original)
+ case "cancelDisposal":
+ // 유찰취소
+ handleCancelDisposal(rowAction.row.original)
break
default:
break
@@ -163,6 +178,8 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { filterFields,
enablePinning: true,
enableAdvancedFilter: true,
+ enableRowSelection: true,
+ singleRowSelection: true,
initialState: {
sorting: [{ id: "disposalDate", desc: true }], // 유찰일 기준 최신순
columnPinning: { right: ["actions"] },
@@ -176,17 +193,85 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { setIsCompact(compact)
}, [])
- const handleSpecMeetingDialogClose = React.useCallback(() => {
- setSpecMeetingDialogOpen(false)
+ const handleBiddingClosureDialogClose = React.useCallback(() => {
+ setBiddingClosureDialogOpen(false)
setRowAction(null)
setSelectedBidding(null)
}, [])
- const handlePrDocumentsDialogClose = React.useCallback(() => {
- setPrDocumentsDialogOpen(false)
- setRowAction(null)
- setSelectedBidding(null)
- }, [])
+ const handleRebid = React.useCallback(async (bidding: BiddingFailureItem) => {
+ if (!session?.user?.id) {
+ toast({
+ title: "오류",
+ description: "사용자 정보를 찾을 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ try {
+ const result = await increaseRoundOrRebid(bidding.id, session.user.id, 'rebidding')
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ })
+ // 페이지 새로고침
+ router.refresh()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('재입찰 실패:', error)
+ toast({
+ title: "오류",
+ description: "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ }, [session?.user?.id, toast, router])
+
+ const handleCancelDisposal = React.useCallback(async (bidding: BiddingFailureItem) => {
+ if (!session?.user?.id) {
+ toast({
+ title: "오류",
+ description: "사용자 정보를 찾을 수 없습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ try {
+ const result = await cancelDisposalAction(bidding.id, session.user.id)
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ })
+ // 페이지 새로고침
+ router.refresh()
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "유찰취소 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('유찰취소 실패:', error)
+ toast({
+ title: "오류",
+ description: "유찰취소 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ }, [session?.user?.id, toast, router])
return (
<>
@@ -202,22 +287,137 @@ export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) { compactStorageKey="biddingsFailureTableCompact"
onCompactChange={handleCompactChange}
>
+ {/* Toolbar 액션 버튼들 */}
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 0) {
+ toast({
+ title: "선택 필요",
+ description: "폐찰할 입찰을 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ if (selectedRows.length > 1) {
+ toast({
+ title: "하나만 선택",
+ description: "폐찰은 한 개의 입찰만 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ const bidding = selectedRows[0].original
+ if (bidding.status !== 'bidding_disposal') {
+ toast({
+ title: "유찰 상태만 가능",
+ description: "유찰 상태인 입찰만 폐찰할 수 있습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ setSelectedBidding(bidding)
+ setBiddingClosureDialogOpen(true)
+ }}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
+ (table.getFilteredSelectedRowModel().rows.length === 1 &&
+ table.getFilteredSelectedRowModel().rows[0].original.status === 'bid_closure')}
+ >
+ <FileX className="mr-2 h-4 w-4" />
+ 폐찰
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 0) {
+ toast({
+ title: "선택 필요",
+ description: "재입찰할 입찰을 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ if (selectedRows.length > 1) {
+ toast({
+ title: "하나만 선택",
+ description: "재입찰은 한 개의 입찰만 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ const bidding = selectedRows[0].original
+ handleRebid(bidding)
+ }}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
+ (table.getFilteredSelectedRowModel().rows.length === 1 &&
+ table.getFilteredSelectedRowModel().rows[0].original.status === 'bid_closure')}
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 재입찰
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ if (selectedRows.length === 0) {
+ toast({
+ title: "선택 필요",
+ description: "유찰취소할 입찰을 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ if (selectedRows.length > 1) {
+ toast({
+ title: "하나만 선택",
+ description: "유찰취소는 한 개의 입찰만 선택해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+ const bidding = selectedRows[0].original
+ if (bidding.status !== 'bidding_disposal' && bidding.status !== 'bid_closure') {
+ toast({
+ title: "유찰/폐찰 상태만 가능",
+ description: "유찰 또는 폐찰 상태인 입찰만 취소할 수 있습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ handleCancelDisposal(bidding)
+ }}
+ disabled={table.getFilteredSelectedRowModel().rows.length !== 1 ||
+ (table.getFilteredSelectedRowModel().rows.length === 1 &&
+ table.getFilteredSelectedRowModel().rows[0].original.status === 'bid_closure')}
+ >
+ <Undo2 className="mr-2 h-4 w-4" />
+ 유찰취소
+ </Button>
+ </div>
</DataTableAdvancedToolbar>
</DataTable>
- {/* 사양설명회 다이얼로그 */}
- <SpecificationMeetingDialog
- open={specMeetingDialogOpen}
- onOpenChange={handleSpecMeetingDialogClose}
- bidding={selectedBidding}
- />
-
- {/* PR 문서 다이얼로그 */}
- <PrDocumentsDialog
- open={prDocumentsDialogOpen}
- onOpenChange={handlePrDocumentsDialogClose}
- bidding={selectedBidding}
- />
+ {/* 폐찰 다이얼로그 */}
+ {selectedBidding && session?.user?.id && (
+ <BidClosureDialog
+ open={biddingClosureDialogOpen}
+ onOpenChange={handleBiddingClosureDialogClose}
+ bidding={selectedBidding}
+ userId={session.user.id}
+ onSuccess={() => {
+ router.refresh()
+ handleBiddingClosureDialogClose()
+ }}
+ />
+ )}
</>
)
}
diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx index 065000ce..c7045c51 100644 --- a/lib/bidding/list/bidding-detail-dialogs.tsx +++ b/lib/bidding/list/bidding-detail-dialogs.tsx @@ -359,128 +359,6 @@ export function SpecificationMeetingDialog({ // PR 문서 다이얼로그는 bidding-pr-documents-dialog.tsx로 이동됨 // import { PrDocumentsDialog } from './bidding-pr-documents-dialog'로 사용하세요 -// 폐찰하기 다이얼로그 -interface BidClosureDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - bidding: BiddingListItem | null; - userId: string; -} - -export function BidClosureDialog({ - open, - onOpenChange, - bidding, - userId -}: BidClosureDialogProps) { - const [description, setDescription] = useState('') - const [files, setFiles] = useState<File[]>([]) - const [isSubmitting, setIsSubmitting] = useState(false) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!bidding || !description.trim()) { - toast.error('폐찰 사유를 입력해주세요.') - return - } - - setIsSubmitting(true) - - try { - const result = await bidClosureAction(bidding.id, { - description: description.trim(), - files - }, userId) - - if (result.success) { - toast.success(result.message) - onOpenChange(false) - // 페이지 새로고침 또는 상태 업데이트 - window.location.reload() - } else { - toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.') - } - } catch (error) { - toast.error('폐찰 처리 중 오류가 발생했습니다.') - } finally { - setIsSubmitting(false) - } - } - - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - if (e.target.files) { - setFiles(Array.from(e.target.files)) - } - } - - if (!bidding) return null - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-2xl"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <FileXIcon className="h-5 w-5 text-destructive" /> - 폐찰하기 - </DialogTitle> - <DialogDescription> - {bidding.title} ({bidding.biddingNumber})를 폐찰합니다. - </DialogDescription> - </DialogHeader> - - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="space-y-2"> - <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label> - <Textarea - id="description" - placeholder="폐찰 사유를 입력해주세요..." - value={description} - onChange={(e) => setDescription(e.target.value)} - className="min-h-[100px]" - required - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="files">첨부파일</Label> - <Input - id="files" - type="file" - multiple - onChange={handleFileChange} - className="cursor-pointer" - accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png" - /> - {files.length > 0 && ( - <div className="text-sm text-muted-foreground"> - 선택된 파일: {files.map(f => f.name).join(', ')} - </div> - )} - </div> - - <div className="flex justify-end gap-2 pt-4"> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button - type="submit" - variant="destructive" - disabled={isSubmitting || !description.trim()} - > - {isSubmitting ? '처리 중...' : '폐찰하기'} - </Button> - </div> - </form> - </DialogContent> - </Dialog> - ) -} // Re-export for backward compatibility export { PrDocumentsDialog } from './bidding-pr-documents-dialog'
\ No newline at end of file diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 10966e0e..f5e77d03 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -136,7 +136,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />, cell: ({ row }) => ( <div className="truncate max-w-[200px]" title={row.original.title}> - <Button + {/* <Button variant="link" className="p-0 h-auto text-left justify-start font-bold underline" onClick={() => setRowAction({ row, type: "view" })} @@ -144,7 +144,8 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef <div className="whitespace-pre-line"> {row.original.title} </div> - </Button> + </Button> */} + {row.original.title} </div> ), size: 200, @@ -389,7 +390,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef <Eye className="mr-2 h-4 w-4" /> 상세보기 </DropdownMenuItem> - <DropdownMenuItem + {/* <DropdownMenuItem onClick={() => setRowAction({ row, type: "update" })} disabled={['bidding_opened', 'bidding_closed', 'vendor_selected'].includes(row.original.status)} > @@ -398,8 +399,8 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef {['bidding_opened', 'bidding_closed', 'vendor_selected'].includes(row.original.status) && ( <span className="text-xs text-muted-foreground ml-2">(수정 불가)</span> )} - </DropdownMenuItem> - <DropdownMenuSeparator /> + </DropdownMenuItem> */} + {/* <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => setRowAction({ row, type: "bid_closure" })} disabled={row.original.status !== 'bidding_disposal'} @@ -409,7 +410,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef {row.original.status !== 'bidding_disposal' && ( <span className="text-xs text-muted-foreground ml-2">(유찰 시에만 가능)</span> )} - </DropdownMenuItem> + </DropdownMenuItem> */} {/* <DropdownMenuSeparator /> <DropdownMenuItem onClick={() => setRowAction({ row, type: "copy" })}> <Package className="mr-2 h-4 w-4" /> diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx index 39952d5a..89b6260c 100644 --- a/lib/bidding/list/biddings-table.tsx +++ b/lib/bidding/list/biddings-table.tsx @@ -23,8 +23,7 @@ import { biddingTypeLabels } from "@/db/schema" import { EditBiddingSheet } from "./edit-bidding-sheet" -import { SpecificationMeetingDialog, PrDocumentsDialog, BidClosureDialog } from "./bidding-detail-dialogs" - +import { SpecificationMeetingDialog, PrDocumentsDialog } from "./bidding-detail-dialogs" interface BiddingsTableProps { promises: Promise< @@ -44,7 +43,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { const [isCompact, setIsCompact] = React.useState<boolean>(false) const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false) const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false) - const [bidClosureDialogOpen, setBidClosureDialogOpen] = React.useState(false) const [selectedBidding, setSelectedBidding] = React.useState<BiddingListItemWithManagerCode | null>(null) console.log(data,"data") @@ -78,10 +76,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { case "pr_documents": setPrDocumentsDialogOpen(true) break - case "bid_closure": - setBidClosureDialogOpen(true) - break - // 기존 다른 액션들은 그대로 유지 default: break } @@ -160,12 +154,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { setSelectedBidding(null) }, []) - const handleBidClosureDialogClose = React.useCallback(() => { - setBidClosureDialogOpen(false) - setRowAction(null) - setSelectedBidding(null) - }, []) - return ( <> @@ -208,13 +196,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { bidding={selectedBidding} /> - {/* 폐찰하기 다이얼로그 */} - <BidClosureDialog - open={bidClosureDialogOpen} - onOpenChange={handleBidClosureDialogClose} - bidding={selectedBidding} - userId={session?.user?.id ? String(session.user.id) : ''} - /> </> ) diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index 20ea740f..ff68e739 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -62,8 +62,6 @@ import { createBiddingSchema, type CreateBiddingSchema } from '@/lib/bidding/val import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' import { cn } from '@/lib/utils' -import { MaterialGroupSingleSelector } from '@/components/common/material/material-group-single-selector' -import { MaterialSingleSelector } from '@/components/common/selectors/material/material-single-selector' import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector' import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager/procurement-manager-selector' import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector' diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx index 724a7396..d5798782 100644 --- a/lib/bidding/receive/biddings-receive-columns.tsx +++ b/lib/bidding/receive/biddings-receive-columns.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { type ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
import {
Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
} from "lucide-react"
@@ -91,6 +92,25 @@ const formatCurrency = (amount: string | number | null, currency = 'KRW') => { export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
return [
+ // ░░░ 선택 ░░░
+ {
+ id: "select",
+ header: "",
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => {
+ // single select 모드에서는 다른 행들의 선택을 해제
+ row.toggleSelected(!!value)
+ }}
+ aria-label="행 선택"
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
// ░░░ 입찰번호 ░░░
{
accessorKey: "biddingNumber",
@@ -110,7 +130,7 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
cell: ({ row }) => (
<div className="truncate max-w-[200px]" title={row.original.title}>
- <Button
+ {/* <Button
variant="link"
className="p-0 h-auto text-left justify-start font-bold underline"
onClick={() => setRowAction({ row, type: "view" })}
@@ -118,7 +138,8 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co <div className="whitespace-pre-line">
{row.original.title}
</div>
- </Button>
+ </Button> */}
+ {row.original.title}
</div>
),
size: 200,
@@ -175,8 +196,8 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
const now = new Date()
- const isActive = now >= new Date(startDate) && now <= new Date(endDate)
- const isPast = now > new Date(endDate)
+ const isActive = now >= startDate && now <= endDate
+ const isPast = now > endDate
return (
<div className="text-xs">
@@ -315,7 +336,7 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.createdAt, "KR")}</span>
+ <span className="text-sm">{row.original.createdAt ? formatDate(row.original.createdAt, "KR") : '-'}</span>
),
size: 100,
meta: { excelHeader: "등록일시" },
@@ -324,37 +345,28 @@ export function getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): Co // ═══════════════════════════════════════════════════════════════
// 액션
// ═══════════════════════════════════════════════════════════════
- {
- id: "actions",
- header: "액션",
- cell: ({ row }) => (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" className="h-8 w-8 p-0">
- <span className="sr-only">메뉴 열기</span>
- <AlertTriangle className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
- <Eye className="mr-2 h-4 w-4" />
- 상세보기
- </DropdownMenuItem>
- {row.original.status === 'bidding_closed' && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "open_bidding" })}>
- <Calendar className="mr-2 h-4 w-4" />
- 개찰하기
- </DropdownMenuItem>
- </>
- )}
- </DropdownMenuContent>
- </DropdownMenu>
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
+ // {
+ // id: "actions",
+ // header: "액션",
+ // cell: ({ row }) => (
+ // <DropdownMenu>
+ // <DropdownMenuTrigger asChild>
+ // <Button variant="ghost" className="h-8 w-8 p-0">
+ // <span className="sr-only">메뉴 열기</span>
+ // <AlertTriangle className="h-4 w-4" />
+ // </Button>
+ // </DropdownMenuTrigger>
+ // <DropdownMenuContent align="end">
+ // <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ // <Eye className="mr-2 h-4 w-4" />
+ // 상세보기
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // ),
+ // size: 50,
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
]
}
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx index 88fade40..873f3fa4 100644 --- a/lib/bidding/receive/biddings-receive-table.tsx +++ b/lib/bidding/receive/biddings-receive-table.tsx @@ -8,6 +8,9 @@ import type { DataTableFilterField,
DataTableRowAction,
} from "@/types/table"
+import { Button } from "@/components/ui/button"
+import { Loader2 } from "lucide-react"
+import { toast } from "sonner"
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
@@ -18,7 +21,8 @@ import { biddingStatusLabels,
contractTypeLabels,
} from "@/db/schema"
-import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+// import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { openBiddingAction, earlyOpenBiddingAction } from "@/lib/bidding/actions"
type BiddingReceiveItem = {
id: number
@@ -62,11 +66,13 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { const { data, pageCount } = biddingsResult
const [isCompact, setIsCompact] = React.useState<boolean>(false)
- const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
- const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
- const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
+ // const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ // const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ // const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
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()
@@ -86,10 +92,6 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { // 상세 페이지로 이동
router.push(`/evcp/bid/${rowAction.row.original.id}`)
break
- case "open_bidding":
- // 개찰하기 (추후 구현)
- console.log('개찰하기:', rowAction.row.original)
- break
default:
break
}
@@ -100,19 +102,16 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { {
id: "biddingNumber",
label: "입찰번호",
- type: "text",
placeholder: "입찰번호를 입력하세요",
},
{
id: "prNumber",
label: "P/R번호",
- type: "text",
placeholder: "P/R번호를 입력하세요",
},
{
id: "title",
label: "입찰명",
- type: "text",
placeholder: "입찰명을 입력하세요",
},
]
@@ -151,6 +150,7 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { filterFields,
enablePinning: true,
enableAdvancedFilter: true,
+ enableRowSelection: true,
initialState: {
sorting: [{ id: "createdAt", desc: true }],
columnPinning: { right: ["actions"] },
@@ -164,17 +164,96 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { setIsCompact(compact)
}, [])
- const handleSpecMeetingDialogClose = React.useCallback(() => {
- setSpecMeetingDialogOpen(false)
- setRowAction(null)
- setSelectedBidding(null)
- }, [])
+ // const handleSpecMeetingDialogClose = React.useCallback(() => {
+ // setSpecMeetingDialogOpen(false)
+ // setRowAction(null)
+ // setSelectedBidding(null)
+ // }, [])
- const handlePrDocumentsDialogClose = React.useCallback(() => {
- setPrDocumentsDialogOpen(false)
- setRowAction(null)
- setSelectedBidding(null)
- }, [])
+ // const handlePrDocumentsDialogClose = React.useCallback(() => {
+ // setPrDocumentsDialogOpen(false)
+ // setRowAction(null)
+ // setSelectedBidding(null)
+ // }, [])
+
+ // 선택된 행 가져오기
+ 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])
+
+ const handleEarlyOpenBidding = React.useCallback(async () => {
+ if (!selectedBiddingForAction) return
+
+ 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)
+ }
+ }, [selectedBiddingForAction])
+
+ const handleOpenBidding = React.useCallback(async () => {
+ if (!selectedBiddingForAction) return
+
+ setIsOpeningBidding(true)
+ try {
+ const result = await openBiddingAction(selectedBiddingForAction.id)
+ if (result.success) {
+ toast.success("개찰이 완료되었습니다.")
+ // 데이터 리프레시
+ window.location.reload()
+ } else {
+ toast.error(result.message || "개찰에 실패했습니다.")
+ }
+ } catch (error) {
+ toast.error("개찰 중 오류가 발생했습니다.")
+ } finally {
+ setIsOpeningBidding(false)
+ }
+ }, [selectedBiddingForAction])
return (
<>
@@ -190,22 +269,42 @@ export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) { compactStorageKey="biddingsReceiveTableCompact"
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 || !canOpen || isOpeningBidding}
+ >
+ {isOpeningBidding && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 개찰
+ </Button>
+ </div>
</DataTableAdvancedToolbar>
</DataTable>
{/* 사양설명회 다이얼로그 */}
- <SpecificationMeetingDialog
+ {/* <SpecificationMeetingDialog
open={specMeetingDialogOpen}
onOpenChange={handleSpecMeetingDialogClose}
bidding={selectedBidding}
- />
+ /> */}
{/* PR 문서 다이얼로그 */}
- <PrDocumentsDialog
+ {/* <PrDocumentsDialog
open={prDocumentsDialogOpen}
onOpenChange={handlePrDocumentsDialogClose}
bidding={selectedBidding}
- />
+ /> */}
</>
)
}
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts new file mode 100644 index 00000000..e17e9292 --- /dev/null +++ b/lib/bidding/selection/actions.ts @@ -0,0 +1,219 @@ +"use server" + +import db from "@/db/db" +import { eq, and, sql, isNull } from "drizzle-orm" +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +// @ts-ignore - Next.js cache import issue in server actions +const { revalidatePath } = require("next/cache") +import { + biddings, + biddingCompanies, + prItemsForBidding, + companyPrItemBids, + vendors, + generalContracts, + generalContractItems, + vendorSelectionResults, + biddingDocuments +} from "@/db/schema" + +interface SaveSelectionResultData { + biddingId: number + summary: string + attachments?: File[] +} + +export async function saveSelectionResult(data: SaveSelectionResultData) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '인증되지 않은 사용자입니다.' + } + } + + // 기존 선정결과 확인 (selectedCompanyId가 null인 레코드) + // 타입 에러를 무시하고 전체 조회 후 필터링 + const allResults = await db + .select() + .from(vendorSelectionResults) + .where(eq(vendorSelectionResults.biddingId, data.biddingId)) + + // @ts-ignore + const existingResult = allResults.filter((result: any) => result.selectedCompanyId === null).slice(0, 1) + + const resultData = { + biddingId: data.biddingId, + selectedCompanyId: null, // 전체 선정결과 + selectionReason: '전체 선정결과', + evaluationSummary: data.summary, + hasResultDocuments: data.attachments && data.attachments.length > 0, + selectedBy: session.user.id + } + + let resultId: number + + if (existingResult.length > 0) { + // 업데이트 + await db + .update(vendorSelectionResults) + .set({ + ...resultData, + updatedAt: new Date() + }) + .where(eq(vendorSelectionResults.id, existingResult[0].id)) + resultId = existingResult[0].id + } else { + // 새로 생성 + const insertResult = await db.insert(vendorSelectionResults).values(resultData).returning({ id: vendorSelectionResults.id }) + resultId = insertResult[0].id + } + + // 첨부파일 처리 + if (data.attachments && data.attachments.length > 0) { + // 기존 첨부파일 삭제 (documentType이 'selection_result'인 것들) + await db + .delete(biddingDocuments) + .where(and( + eq(biddingDocuments.biddingId, data.biddingId), + eq(biddingDocuments.documentType, 'selection_result') + )) + + // 새 첨부파일 저장 + const documentInserts = data.attachments.map(file => ({ + biddingId: data.biddingId, + companyId: null, + documentType: 'selection_result' as const, + fileName: file.name, + originalFileName: file.name, + fileSize: file.size, + mimeType: file.type, + filePath: `/uploads/bidding/${data.biddingId}/selection/${file.name}`, // 실제 파일 저장 로직 필요 + uploadedBy: session.user.id + })) + + await db.insert(biddingDocuments).values(documentInserts) + } + + revalidatePath(`/evcp/bid-selection/${data.biddingId}/detail`) + + return { + success: true, + message: '선정결과가 성공적으로 저장되었습니다.' + } + } catch (error) { + console.error('Failed to save selection result:', error) + return { + success: false, + error: '선정결과 저장 중 오류가 발생했습니다.' + } + } +} + +// 견적 히스토리 조회 +export async function getQuotationHistory(biddingId: number, vendorId: number) { + try { + // biddingCompanies에서 해당 벤더의 스냅샷 데이터 조회 + const companyData = await db + .select({ + quotationSnapshots: biddingCompanies.quotationSnapshots + }) + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, biddingId), + eq(biddingCompanies.companyId, vendorId) + )) + .limit(1) + + if (!companyData.length || !companyData[0].quotationSnapshots) { + return { + success: true, + data: { + history: [] + } + } + } + + const snapshots = companyData[0].quotationSnapshots as any[] + + // PR 항목 정보 조회 (스냅샷의 prItemId로 매핑하기 위해) + const prItemIds = snapshots.flatMap(snapshot => + snapshot.items?.map((item: any) => item.prItemId) || [] + ).filter((id: number, index: number, arr: number[]) => arr.indexOf(id) === index) + + const prItems = prItemIds.length > 0 ? await db + .select({ + id: prItemsForBidding.id, + itemCode: prItemsForBidding.itemCode, + itemName: prItemsForBidding.itemName, + specification: prItemsForBidding.specification, + quantity: prItemsForBidding.quantity, + unit: prItemsForBidding.unit, + deliveryDate: prItemsForBidding.deliveryDate + }) + .from(prItemsForBidding) + .where(sql`${prItemsForBidding.id} IN ${prItemIds}`) : [] + + // PR 항목을 Map으로 변환하여 빠른 조회를 위해 + const prItemMap = new Map(prItems.map(item => [item.id, item])) + + // bidding 정보 조회 (targetPrice, currency) + const biddingInfo = await db + .select({ + targetPrice: biddings.targetPrice, + currency: biddings.currency + }) + .from(biddings) + .where(eq(biddings.id, biddingId)) + .limit(1) + + const targetPrice = biddingInfo[0]?.targetPrice ? parseFloat(biddingInfo[0].targetPrice.toString()) : null + const currency = biddingInfo[0]?.currency || 'KRW' + + // 스냅샷 데이터를 변환 + const history = snapshots.map((snapshot: any) => { + const vsTargetPrice = targetPrice && targetPrice > 0 + ? ((snapshot.totalAmount - targetPrice) / targetPrice) * 100 + : 0 + + const items = snapshot.items?.map((item: any) => { + const prItem = prItemMap.get(item.prItemId) + return { + itemCode: prItem?.itemCode || `ITEM${item.prItemId}`, + itemName: prItem?.itemName || '품목 정보 없음', + specification: prItem?.specification || item.technicalSpecification || '-', + quantity: prItem?.quantity || 0, + unit: prItem?.unit || 'EA', + unitPrice: item.bidUnitPrice, + totalPrice: item.bidAmount, + deliveryDate: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate) : prItem?.deliveryDate ? new Date(prItem.deliveryDate) : new Date() + } + }) || [] + + return { + id: snapshot.id, + round: snapshot.round, + submittedAt: new Date(snapshot.submittedAt), + totalAmount: snapshot.totalAmount, + currency: snapshot.currency || currency, + vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)), + items + } + }) + + return { + success: true, + data: { + history + } + } + } catch (error) { + console.error('Failed to get quotation history:', error) + return { + success: false, + error: '견적 히스토리 조회 중 오류가 발생했습니다.' + } + } +} diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx new file mode 100644 index 00000000..f6f0bc69 --- /dev/null +++ b/lib/bidding/selection/bidding-info-card.tsx @@ -0,0 +1,96 @@ +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +// import { formatDate } from '@/lib/utils' +import { biddingStatusLabels, contractTypeLabels } from '@/db/schema' + +interface BiddingInfoCardProps { + bidding: Bidding +} + +export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) { + return ( + <Card> + <CardHeader> + <CardTitle>입찰정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> + {/* 입찰명 */} + <div className="space-y-2"> + <label className="text-sm font-medium text-muted-foreground"> + 입찰명 + </label> + <div className="text-sm font-medium"> + {bidding.title || '-'} + </div> + </div> + + {/* 입찰번호 */} + <div className="space-y-2"> + <label className="text-sm font-medium text-muted-foreground"> + 입찰번호 + </label> + <div className="text-sm font-medium"> + {bidding.biddingNumber || '-'} + </div> + </div> + + {/* 내정가 */} + <div className="space-y-2"> + <label className="text-sm font-medium text-muted-foreground"> + 내정가 + </label> + <div className="text-sm font-medium"> + {bidding.targetPrice + ? `${Number(bidding.targetPrice).toLocaleString()} ${bidding.currency || 'KRW'}` + : '-' + } + </div> + </div> + + {/* 입찰유형 */} + <div className="space-y-2"> + <label className="text-sm font-medium text-muted-foreground"> + 입찰유형 + </label> + <div className="text-sm font-medium"> + {bidding.isPublic ? '공개입찰' : '비공개입찰'} + </div> + </div> + + {/* 진행상태 */} + <div className="space-y-2"> + <label className="text-sm font-medium text-muted-foreground"> + 진행상태 + </label> + <Badge variant="secondary"> + {biddingStatusLabels[bidding.status as keyof typeof biddingStatusLabels] || bidding.status} + </Badge> + </div> + + {/* 입찰담당자 */} + <div className="space-y-2"> + <label className="text-sm font-medium text-muted-foreground"> + 입찰담당자 + </label> + <div className="text-sm font-medium"> + {bidding.bidPicName || '-'} + </div> + </div> + + {/* 계약구분 */} + <div className="space-y-2"> + <label className="text-sm font-medium text-muted-foreground"> + 계약구분 + </label> + <div className="text-sm font-medium"> + {contractTypeLabels[bidding.contractType as keyof typeof contractTypeLabels] || bidding.contractType} + </div> + </div> + </div> + </CardContent> + </Card> + ) +} diff --git a/lib/bidding/selection/bidding-selection-detail-content.tsx b/lib/bidding/selection/bidding-selection-detail-content.tsx new file mode 100644 index 00000000..45d5d402 --- /dev/null +++ b/lib/bidding/selection/bidding-selection-detail-content.tsx @@ -0,0 +1,41 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { BiddingInfoCard } from './bidding-info-card' +import { SelectionResultForm } from './selection-result-form' +import { VendorSelectionTable } from './vendor-selection-table' + +interface BiddingSelectionDetailContentProps { + biddingId: number + bidding: Bidding +} + +export function BiddingSelectionDetailContent({ + biddingId, + bidding +}: BiddingSelectionDetailContentProps) { + const [refreshKey, setRefreshKey] = React.useState(0) + + const handleRefresh = React.useCallback(() => { + setRefreshKey(prev => prev + 1) + }, []) + + return ( + <div className="space-y-6"> + {/* 입찰정보 카드 */} + <BiddingInfoCard bidding={bidding} /> + + {/* 선정결과 폼 */} + <SelectionResultForm biddingId={biddingId} onSuccess={handleRefresh} /> + + {/* 업체선정 테이블 */} + <VendorSelectionTable + key={refreshKey} + biddingId={biddingId} + bidding={bidding} + onRefresh={handleRefresh} + /> + </div> + ) +} diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx index bbcd2d77..0d1a8c9d 100644 --- a/lib/bidding/selection/biddings-selection-columns.tsx +++ b/lib/bidding/selection/biddings-selection-columns.tsx @@ -256,10 +256,6 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): <Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "detail" })}>
- <FileText className="mr-2 h-4 w-4" />
- 상세분석
- </DropdownMenuItem>
{row.original.status === 'bidding_opened' && (
<>
<DropdownMenuSeparator />
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx index 912a7154..9545fe09 100644 --- a/lib/bidding/selection/biddings-selection-table.tsx +++ b/lib/bidding/selection/biddings-selection-table.tsx @@ -83,10 +83,6 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
- break
- case "detail":
- // 상세분석 페이지로 이동 (추후 구현)
router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
break
case "close_bidding":
diff --git a/lib/bidding/selection/selection-result-form.tsx b/lib/bidding/selection/selection-result-form.tsx new file mode 100644 index 00000000..7f1229a2 --- /dev/null +++ b/lib/bidding/selection/selection-result-form.tsx @@ -0,0 +1,143 @@ +'use client' + +import * as React from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import * as z from 'zod' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { FileUpload } from '@/components/ui/file-upload' +import { useToast } from '@/hooks/use-toast' +import { saveSelectionResult } from './actions' +import { Loader2, Save } from 'lucide-react' + +const selectionResultSchema = z.object({ + summary: z.string().min(1, '결과요약을 입력해주세요'), + attachments: z.array(z.any()).optional(), +}) + +type SelectionResultFormData = z.infer<typeof selectionResultSchema> + +interface SelectionResultFormProps { + biddingId: number + onSuccess: () => void +} + +export function SelectionResultForm({ biddingId, onSuccess }: SelectionResultFormProps) { + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = React.useState(false) + + const form = useForm<SelectionResultFormData>({ + resolver: zodResolver(selectionResultSchema), + defaultValues: { + summary: '', + attachments: [], + }, + }) + + const onSubmit = async (data: SelectionResultFormData) => { + setIsSubmitting(true) + try { + const result = await saveSelectionResult({ + biddingId, + summary: data.summary, + attachments: data.attachments + }) + + if (result.success) { + toast({ + title: '저장 완료', + description: result.message, + }) + onSuccess() + } else { + toast({ + title: '저장 실패', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + console.error('Failed to save selection result:', error) + toast({ + title: '저장 실패', + description: '선정결과 저장 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + <Card> + <CardHeader> + <CardTitle>선정결과</CardTitle> + </CardHeader> + <CardContent> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + {/* 결과요약 */} + <FormField + control={form.control} + name="summary" + render={({ field }) => ( + <FormItem> + <FormLabel>결과요약</FormLabel> + <FormControl> + <Textarea + placeholder="선정결과에 대한 요약을 입력해주세요..." + className="min-h-[120px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 첨부파일 */} + <FormField + control={form.control} + name="attachments" + render={({ field }) => ( + <FormItem> + <FormLabel>첨부파일</FormLabel> + <FormControl> + <FileUpload + value={field.value || []} + onChange={field.onChange} + accept={{ + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + }} + maxSize={10 * 1024 * 1024} // 10MB + maxFiles={5} + placeholder="선정결과 관련 문서를 업로드해주세요" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 저장 버튼 */} + <div className="flex justify-end"> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Save className="mr-2 h-4 w-4" /> + 저장 + </Button> + </div> + </form> + </Form> + </CardContent> + </Card> + ) +} diff --git a/lib/bidding/selection/vendor-selection-table.tsx b/lib/bidding/selection/vendor-selection-table.tsx new file mode 100644 index 00000000..8570b5b6 --- /dev/null +++ b/lib/bidding/selection/vendor-selection-table.tsx @@ -0,0 +1,66 @@ +'use client' + +import * as React from 'react' +import { Bidding } from '@/db/schema' +import { BiddingDetailVendorTableContent } from '../detail/table/bidding-detail-vendor-table' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { getBiddingDetailData } from '../detail/service' + +interface VendorSelectionTableProps { + biddingId: number + bidding: Bidding + onRefresh: () => void +} + +export function VendorSelectionTable({ biddingId, bidding, onRefresh }: VendorSelectionTableProps) { + const [vendors, setVendors] = React.useState<any[]>([]) + const [loading, setLoading] = React.useState(true) + + React.useEffect(() => { + const loadData = async () => { + try { + setLoading(true) + const data = await getBiddingDetailData(biddingId) + setVendors(data.quotationVendors) + } catch (error) { + console.error('Failed to load vendors:', error) + } finally { + setLoading(false) + } + } + + loadData() + }, [biddingId]) + + if (loading) { + return ( + <Card> + <CardHeader> + <CardTitle>업체선정</CardTitle> + </CardHeader> + <CardContent> + <div className="flex items-center justify-center py-8"> + <div className="text-sm text-muted-foreground">로딩 중...</div> + </div> + </CardContent> + </Card> + ) + } + + return ( + <Card> + <CardHeader> + <CardTitle>업체선정</CardTitle> + </CardHeader> + <CardContent> + <BiddingDetailVendorTableContent + biddingId={biddingId} + bidding={bidding} + vendors={vendors} + onRefresh={onRefresh} + onOpenSelectionReasonDialog={() => {}} + /> + </CardContent> + </Card> + ) +} diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 80e4850f..a7cd8286 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -3076,7 +3076,14 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId } // 차수증가 또는 재입찰 함수 -export async function increaseRoundOrRebid(biddingId: number, userId: string, type: 'round_increase' | 'rebidding') { +export async function increaseRoundOrRebid(biddingId: number, userId: string | undefined, type: 'round_increase' | 'rebidding') { + if (!userId) { + return { + success: false, + error: '사용자 정보가 필요합니다.', + } + } + try { const userName = await getUserNameById(userId) @@ -3429,7 +3436,8 @@ export async function getBiddingsForReceive(input: GetBiddingsSchema) { or( eq(biddings.status, 'received_quotation'), eq(biddings.status, 'bidding_opened'), - eq(biddings.status, 'bidding_closed') + eq(biddings.status, 'bidding_closed'), + eq(biddings.status, 'evaluation_of_bidding'), )! ) @@ -3577,9 +3585,9 @@ export async function getBiddingsForReceive(input: GetBiddingsSchema) { ), 0) `.as('participant_pending'), - // 개찰 정보 (bidding_opened 상태에서만 의미 있음) - openedAt: biddings.updatedAt, // 개찰일은 업데이트 일시로 대체 - openedBy: biddings.updatedBy, // 개찰자는 업데이트자로 대체 + // 개찰 정보 + openedAt: biddings.openedAt, + openedBy: biddings.openedBy, }) .from(biddings) .where(finalWhere) @@ -3756,8 +3764,13 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { // 기본 필터 조건들 (유찰된 입찰만) const basicConditions: SQL<unknown>[] = [] - // 유찰된 상태만 필터링 - basicConditions.push(eq(biddings.status, 'bidding_disposal')) + // 유찰된 상태만 필터링, 폐찰된 상태도 포함 + basicConditions.push( + or( + eq(biddings.status, 'bidding_disposal'), + eq(biddings.status, 'bid_closure') + )! + ) if (input.biddingNumber) { basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`)) @@ -3848,8 +3861,8 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { orderByColumns.push(desc(biddings.updatedAt)) // 유찰된 최신순 } - // bid-failure 페이지용 데이터 조회 - const data = await db + // bid-failure 페이지용 데이터 조회 (폐찰 문서 정보 포함) + const rawData = await db .select({ // 기본 입찰 정보 id: biddings.id, @@ -3878,6 +3891,15 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일 disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자 + // 폐찰 정보 + closureReason: biddings.description, // 폐찰사유 + + // 폐찰 문서 정보 + documentId: biddingDocuments.id, + documentFileName: biddingDocuments.fileName, + documentOriginalFileName: biddingDocuments.originalFileName, + documentFilePath: biddingDocuments.filePath, + // 기타 정보 createdBy: biddings.createdBy, createdAt: biddings.createdAt, @@ -3885,11 +3907,67 @@ export async function getBiddingsForFailure(input: GetBiddingsSchema) { updatedBy: biddings.updatedBy, }) .from(biddings) + .leftJoin(biddingDocuments, and( + eq(biddingDocuments.biddingId, biddings.id), + eq(biddingDocuments.documentType, 'evaluation_doc'), // 폐찰 문서 + eq(biddingDocuments.isPublic, false) // 폐찰 문서는 비공개 + )) .where(finalWhere) .orderBy(...orderByColumns) .limit(input.perPage) .offset(offset) + // 데이터를 그룹화하여 폐찰 문서들을 배열로 묶기 + const groupedData = rawData.reduce((acc, item) => { + const existing = acc.find(b => b.id === item.id) + if (existing) { + // 이미 존재하는 입찰이면 문서 추가 + if (item.documentId) { + existing.closureDocuments.push({ + id: item.documentId, + fileName: item.documentFileName!, + originalFileName: item.documentOriginalFileName!, + filePath: item.documentFilePath! + }) + } + } else { + // 새로운 입찰 추가 + acc.push({ + id: item.id, + biddingNumber: item.biddingNumber, + originalBiddingNumber: item.originalBiddingNumber, + title: item.title, + status: item.status, + contractType: item.contractType, + prNumber: item.prNumber, + targetPrice: item.targetPrice, + currency: item.currency, + biddingRegistrationDate: item.biddingRegistrationDate, + submissionStartDate: item.submissionStartDate, + submissionEndDate: item.submissionEndDate, + bidPicName: item.bidPicName, + supplyPicName: item.supplyPicName, + disposalDate: item.disposalDate, + disposalUpdatedAt: item.disposalUpdatedAt, + disposalUpdatedBy: item.disposalUpdatedBy, + closureReason: item.closureReason, + closureDocuments: item.documentId ? [{ + id: item.documentId, + fileName: item.documentFileName!, + originalFileName: item.documentOriginalFileName!, + filePath: item.documentFilePath! + }] : [], + createdBy: item.createdBy, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + updatedBy: item.updatedBy, + }) + } + return acc + }, [] as any[]) + + const data = groupedData + const pageCount = Math.ceil(total / input.perPage) return { data, pageCount, total } diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 273c0667..fe254dad 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -755,7 +755,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD biddingDetail.isBiddingParticipated === true ? 'default' : 'destructive' }> {biddingDetail.isBiddingParticipated === null ? '참여 결정 대기' : - biddingDetail.isBiddingParticipated === true ? '응찰' : '미응찰'} + biddingDetail.isBiddingParticipated === true ? '응찰' : '응찰포기'} </Badge> </div> @@ -1014,12 +1014,12 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD {/* 참여 상태에 따른 섹션 표시 */} {biddingDetail.isBiddingParticipated === false ? ( - /* 미응찰 상태 표시 */ + /* 응찰포기 상태 표시 */ <Card> <CardHeader> <CardTitle className="flex items-center gap-2"> <XCircle className="w-5 h-5 text-destructive" /> - 입찰 참여 거절 + 응찰포기 </CardTitle> </CardHeader> <CardContent> |
