summaryrefslogtreecommitdiff
path: root/lib/bidding
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-12 10:42:36 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-12 10:42:36 +0000
commit8642ee064ddf96f1db2b948b4cc8bbbd6cfee820 (patch)
tree36bd57d147ba929f1d72918d1fb91ad2c4778624 /lib/bidding
parent57ea2f740abf1c7933671561cfe0e421fb5ef3fc (diff)
(최겸) 구매 일반계약, 입찰 수정
Diffstat (limited to 'lib/bidding')
-rw-r--r--lib/bidding/actions.ts230
-rw-r--r--lib/bidding/detail/service.ts120
-rw-r--r--lib/bidding/detail/table/bidding-detail-content.tsx314
-rw-r--r--lib/bidding/detail/table/bidding-detail-items-dialog.tsx12
-rw-r--r--lib/bidding/detail/table/bidding-detail-target-price-dialog.tsx356
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx18
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-table.tsx53
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx222
-rw-r--r--lib/bidding/detail/table/bidding-vendor-prices-dialog.tsx297
-rw-r--r--lib/bidding/detail/table/quotation-history-dialog.tsx254
-rw-r--r--lib/bidding/failure/biddings-closure-dialog.tsx142
-rw-r--r--lib/bidding/failure/biddings-failure-columns.tsx130
-rw-r--r--lib/bidding/failure/biddings-failure-table.tsx266
-rw-r--r--lib/bidding/list/bidding-detail-dialogs.tsx122
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx13
-rw-r--r--lib/bidding/list/biddings-table.tsx21
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx2
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx86
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx149
-rw-r--r--lib/bidding/selection/actions.ts219
-rw-r--r--lib/bidding/selection/bidding-info-card.tsx96
-rw-r--r--lib/bidding/selection/bidding-selection-detail-content.tsx41
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx4
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx4
-rw-r--r--lib/bidding/selection/selection-result-form.tsx143
-rw-r--r--lib/bidding/selection/vendor-selection-table.tsx66
-rw-r--r--lib/bidding/service.ts96
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx6
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>