summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/detail/service.ts65
-rw-r--r--lib/bidding/detail/table/bidding-detail-header.tsx101
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx41
-rw-r--r--lib/bidding/pre-quote/service.ts48
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx2
-rw-r--r--lib/bidding/service.ts41
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx6
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx190
-rw-r--r--lib/bidding/vendor/partners-bidding-pre-quote.tsx51
9 files changed, 309 insertions, 236 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index d9bcb255..e22331bb 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -1,7 +1,7 @@
'use server'
import db from '@/db/db'
-import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions, priceAdjustmentForms } from '@/db/schema'
+import { biddings, prItemsForBidding, biddingDocuments, biddingCompanies, vendors, companyPrItemBids, companyConditionResponses, vendorSelectionResults, BiddingListItem, biddingConditions, priceAdjustmentForms, users } from '@/db/schema'
import { specificationMeetings } from '@/db/schema/bidding'
import { eq, and, sql, desc, ne } from 'drizzle-orm'
import { revalidatePath, revalidateTag } from 'next/cache'
@@ -9,6 +9,22 @@ import { unstable_cache } from "@/lib/unstable-cache";
import { sendEmail } from '@/lib/mail/sendEmail'
import { saveFile } from '@/lib/file-stroage'
+// userId를 user.name으로 변환하는 유틸리티 함수
+async function getUserNameById(userId: string): Promise<string> {
+ try {
+ const user = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1)
+
+ return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId // 에러 시 userId를 그대로 반환
+ }
+}
+
// 데이터 조회 함수들
export interface BiddingDetailData {
bidding: Awaited<ReturnType<typeof getBiddingById>>
@@ -266,6 +282,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null,
isBiddingParticipated: vendor.isBiddingParticipated,
status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected',
+ documents: [], // 빈 배열로 초기화
}))
} catch (error) {
console.error('Failed to get quotation vendors:', error)
@@ -664,22 +681,20 @@ export async function createBiddingDetailVendor(
// 협력업체 정보 저장 - biddingCompanies와 companyConditionResponses 테이블에 레코드 생성
export async function createQuotationVendor(input: any, userId: string) {
try {
+ const userName = await getUserNameById(userId)
const result = await db.transaction(async (tx) => {
// 1. biddingCompanies에 레코드 생성
const biddingCompanyResult = await tx.insert(biddingCompanies).values({
biddingId: input.biddingId,
companyId: input.vendorId,
- quotationAmount: input.quotationAmount,
- currency: input.currency,
- status: input.status,
- awardRatio: input.awardRatio,
+ finalQuoteAmount: input.quotationAmount?.toString(),
+ awardRatio: input.awardRatio?.toString(),
isWinner: false,
contactPerson: input.contactPerson,
contactEmail: input.contactEmail,
contactPhone: input.contactPhone,
- submissionDate: new Date(),
- createdBy: userId,
- updatedBy: userId,
+ finalQuoteSubmittedAt: new Date(),
+ // 스키마에 createdBy, updatedBy 필드가 없으므로 제거
}).returning({ id: biddingCompanies.id })
if (biddingCompanyResult.length === 0) {
@@ -728,6 +743,7 @@ export async function createQuotationVendor(input: any, userId: string) {
// 협력업체 정보 업데이트
export async function updateQuotationVendor(id: number, input: any, userId: string) {
try {
+ const userName = await getUserNameById(userId)
const result = await db.transaction(async (tx) => {
// 1. biddingCompanies 테이블 업데이트
const updateData: any = {}
@@ -735,9 +751,9 @@ export async function updateQuotationVendor(id: number, input: any, userId: stri
if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson
if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail
if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone
- if (input.awardRatio !== undefined) updateData.awardRatio = input.awardRatio
- if (input.status !== undefined) updateData.status = input.status
- updateData.updatedBy = userId
+ if (input.awardRatio !== undefined) updateData.awardRatio = input.awardRatio?.toString()
+ // status 필드가 스키마에 없으므로 제거
+ // updatedBy 필드가 스키마에 없으므로 제거
updateData.updatedAt = new Date()
if (Object.keys(updateData).length > 0) {
@@ -1031,8 +1047,10 @@ export async function registerBidding(biddingId: number, userId: string) {
// 캐시 무효화
revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-detail')
revalidateTag('quotation-vendors')
revalidateTag('quotation-details')
+ revalidateTag('pr-items')
revalidatePath(`/evcp/bid/${biddingId}`)
return {
@@ -1157,6 +1175,7 @@ export async function createRebidding(biddingId: number, userId: string) {
// 업체 선정 사유 업데이트
export async function updateVendorSelectionReason(biddingId: number, selectedCompanyId: number, selectionReason: string, userId: string) {
try {
+ const userName = await getUserNameById(userId)
// vendorSelectionResults 테이블에 삽입 또는 업데이트
await db
.insert(vendorSelectionResults)
@@ -1164,7 +1183,7 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom
biddingId,
selectedCompanyId,
selectionReason,
- selectedBy: userId,
+ selectedBy: userName,
selectedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date()
@@ -1174,7 +1193,7 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom
set: {
selectedCompanyId,
selectionReason,
- selectedBy: userId,
+ selectedBy: userName,
selectedAt: new Date(),
updatedAt: new Date()
}
@@ -1194,6 +1213,7 @@ export async function updateVendorSelectionReason(biddingId: number, selectedCom
// 낙찰용 문서 업로드
export async function uploadAwardDocument(biddingId: number, file: File, userId: string) {
try {
+ const userName = await getUserNameById(userId)
const saveResult = await saveFile({
file,
directory: `biddings/${biddingId}/award`,
@@ -1211,10 +1231,9 @@ export async function uploadAwardDocument(biddingId: number, file: File, userId:
documentType: 'other',
title: '낙찰 관련 문서',
description: '낙찰 관련 첨부파일',
- uploadedBy: userId,
+ uploadedBy: userName,
uploadedAt: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
+ // createdAt, updatedAt 필드가 스키마에 없으므로 제거
}).returning()
return {
@@ -1292,6 +1311,7 @@ export async function getAwardDocumentForDownload(documentId: number, biddingId:
// 낙찰용 문서 삭제
export async function deleteAwardDocument(documentId: number, biddingId: number, userId: string) {
try {
+ const userName = await getUserNameById(userId)
// 문서 정보 조회
const documents = await db
.select()
@@ -1300,7 +1320,7 @@ export async function deleteAwardDocument(documentId: number, biddingId: number,
eq(biddingDocuments.id, documentId),
eq(biddingDocuments.biddingId, biddingId),
eq(biddingDocuments.documentType, 'other'),
- eq(biddingDocuments.uploadedBy, userId)
+ eq(biddingDocuments.uploadedBy, userName)
))
.limit(1)
@@ -1335,6 +1355,7 @@ export async function deleteAwardDocument(documentId: number, biddingId: number,
// 낙찰 처리 (발주비율과 함께)
export async function awardBidding(biddingId: number, selectionReason: string, userId: string) {
try {
+ const userName = await getUserNameById(userId)
// 낙찰된 업체들 조회 (isWinner가 true인 업체들)
const awardedCompanies = await db
.select({
@@ -1388,7 +1409,7 @@ export async function awardBidding(biddingId: number, selectionReason: string, u
.set({
selectedCompanyId: firstAwardedCompany.companyId,
selectionReason,
- selectedBy: userId,
+ selectedBy: userName,
selectedAt: new Date(),
updatedAt: new Date()
})
@@ -1401,7 +1422,7 @@ export async function awardBidding(biddingId: number, selectionReason: string, u
biddingId,
selectedCompanyId: firstAwardedCompany.companyId,
selectionReason,
- selectedBy: userId,
+ selectedBy: userName,
selectedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date()
@@ -1532,6 +1553,7 @@ export async function saveBiddingDraft(
userId: string
) {
try {
+ const userName = await getUserNameById(userId)
let totalAmount = 0
await db.transaction(async (tx) => {
@@ -1668,7 +1690,7 @@ export interface PartnersBiddingListItem {
// biddings 정보
biddingId: number
biddingNumber: string
- revision: number
+ revision: number | null
projectName: string
itemName: string
title: string
@@ -1883,6 +1905,7 @@ export async function submitPartnerResponse(
userId: string
) {
try {
+ const userName = await getUserNameById(userId)
const result = await db.transaction(async (tx) => {
// 0. 품목별 견적 정보 최종 저장 (본입찰 제출) - Upsert 방식
if (response.prItemQuotations && response.prItemQuotations.length > 0) {
@@ -1981,7 +2004,7 @@ export async function submitPartnerResponse(
const companyUpdateData: any = {
respondedAt: new Date(),
updatedAt: new Date(),
- // updatedBy: userId, // 이 필드가 존재하지 않음
+ // updatedBy: userName, // 이 필드가 존재하지 않음
}
if (response.finalQuoteAmount !== undefined) {
diff --git a/lib/bidding/detail/table/bidding-detail-header.tsx b/lib/bidding/detail/table/bidding-detail-header.tsx
index 2798478c..8d18472f 100644
--- a/lib/bidding/detail/table/bidding-detail-header.tsx
+++ b/lib/bidding/detail/table/bidding-detail-header.tsx
@@ -40,107 +40,6 @@ export function BiddingDetailHeader({ bidding }: BiddingDetailHeaderProps) {
router.push('/evcp/bid')
}
- const handleRegister = () => {
- // 상태 검증
- if (bidding.status !== 'bidding_generated') {
- toast({
- title: '실행 불가',
- description: '입찰 등록은 입찰 생성 상태에서만 가능합니다.',
- variant: 'destructive',
- })
- return
- }
-
- if (!confirm('입찰을 등록하시겠습니까?')) return
-
- startTransition(async () => {
- const result = await registerBidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID
-
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
- router.refresh()
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleMarkAsDisposal = () => {
- // 상태 검증
- if (bidding.status !== 'bidding_closed') {
- toast({
- title: '실행 불가',
- description: '유찰 처리는 입찰 마감 상태에서만 가능합니다.',
- variant: 'destructive',
- })
- return
- }
-
- if (!confirm('입찰을 유찰 처리하시겠습니까?')) return
-
- startTransition(async () => {
- const result = await markAsDisposal(bidding.id, 'current-user') // TODO: 실제 사용자 ID
-
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
- router.refresh()
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleCreateRebidding = () => {
- // 상태 검증
- if (bidding.status !== 'bidding_disposal') {
- toast({
- title: '실행 불가',
- description: '재입찰은 유찰 상태에서만 가능합니다.',
- variant: 'destructive',
- })
- return
- }
-
- if (!confirm('재입찰을 생성하시겠습니까?')) return
-
- startTransition(async () => {
- const result = await createRebidding(bidding.id, 'current-user') // TODO: 실제 사용자 ID
-
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
- // 새로 생성된 입찰로 이동
- if (result.data) {
- router.push(`/evcp/bid/${result.data.id}`)
- } else {
- router.refresh()
- }
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
return (
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="px-6 py-4">
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 64c31633..654d9941 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -106,7 +106,6 @@ export function BiddingDetailVendorToolbarActions({
<>
<div className="flex items-center gap-2">
{/* 상태별 액션 버튼 */}
- {bidding.status === 'set_target_price' && (
<Button
variant="default"
size="sm"
@@ -116,27 +115,25 @@ export function BiddingDetailVendorToolbarActions({
<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>
- </>
+ <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' && (
<Button
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 3f1b916c..e1df986e 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -3,10 +3,28 @@
import db from '@/db/db'
import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
import { vendors } from '@/db/schema/vendors'
+import { users } from '@/db/schema'
import { sendEmail } from '@/lib/mail/sendEmail'
import { eq, inArray, and } from 'drizzle-orm'
import { saveFile } from '@/lib/file-stroage'
import { downloadFile } from '@/lib/file-download'
+import { revalidateTag, revalidatePath } from 'next/cache'
+
+// userId를 user.name으로 변환하는 유틸리티 함수
+async function getUserNameById(userId: string): Promise<string> {
+ try {
+ const user = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1)
+
+ return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId // 에러 시 userId를 그대로 반환
+ }
+}
interface CreateBiddingCompanyInput {
biddingId: number
@@ -130,6 +148,13 @@ export async function updateBiddingCompany(id: number, input: UpdateBiddingCompa
// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능)
export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean, userId: string) {
try {
+ // 업체들의 입찰 ID 조회 (캐시 무효화를 위해)
+ const companies = await db
+ .select({ biddingId: biddingCompanies.biddingId })
+ .from(biddingCompanies)
+ .where(inArray(biddingCompanies.id, companyIds))
+ .limit(1)
+
await db.update(biddingCompanies)
.set({
isPreQuoteSelected: isSelected,
@@ -138,6 +163,16 @@ export async function updatePreQuoteSelection(companyIds: number[], isSelected:
})
.where(inArray(biddingCompanies.id, companyIds))
+ // 캐시 무효화
+ if (companies.length > 0) {
+ const biddingId = companies[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-detail')
+ revalidateTag('quotation-vendors')
+ revalidateTag('quotation-details')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
const message = isSelected
? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.`
: `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.`
@@ -913,6 +948,7 @@ export async function uploadPreQuoteDocument(
userId: string
) {
try {
+ const userName = await getUserNameById(userId)
// 파일 저장
const saveResult = await saveFile({
file,
@@ -943,7 +979,7 @@ export async function uploadPreQuoteDocument(
description: '협력업체 제출 견적서',
isPublic: false,
isRequired: false,
- uploadedBy: userId,
+ uploadedBy: userName,
uploadedAt: new Date()
})
.returning()
@@ -1107,21 +1143,11 @@ export async function deletePreQuoteDocument(
const doc = document[0]
- // 권한 확인 (업로드한 사용자만 삭제 가능)
- if (doc.uploadedBy !== userId) {
- return {
- success: false,
- error: '삭제 권한이 없습니다.'
- }
- }
-
// 데이터베이스에서 문서 정보 삭제
await db
.delete(biddingDocuments)
.where(eq(biddingDocuments.id, documentId))
- // TODO: 실제 파일도 삭제하는 로직 추가 (필요시)
-
return {
success: true,
message: '문서가 성공적으로 삭제되었습니다.'
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
index 12bd2696..f676709c 100644
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
@@ -19,6 +19,8 @@ interface PrItem {
materialDescription: string | null
quantity: string | null
quantityUnit: string | null
+ totalWeight: string | null
+ weightUnit: string | null
currency: string | null
requestedDeliveryDate: string | null
hasSpecDocument: boolean | null
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index c4904219..7d314841 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -10,7 +10,8 @@ import {
prItemsForBidding,
specificationMeetings,
prDocuments,
- biddingConditions
+ biddingConditions,
+ users
} from '@/db/schema'
import {
eq,
@@ -31,6 +32,22 @@ import { filterColumns } from '@/lib/filter-columns'
import { CreateBiddingSchema, GetBiddingsSchema, UpdateBiddingSchema } from './validation'
import { saveFile } from '../file-stroage'
+// userId를 user.name으로 변환하는 유틸리티 함수
+async function getUserNameById(userId: string): Promise<string> {
+ try {
+ const user = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1)
+
+ return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId // 에러 시 userId를 그대로 반환
+ }
+}
+
export async function getBiddingNoticeTemplate() {
try {
@@ -368,6 +385,10 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
itemInfo: string
quantity: string
quantityUnit: string
+ totalWeight: string
+ weightUnit: string
+ materialDescription: string
+ hasSpecDocument: boolean
requestedDeliveryDate: string
specFiles: File[]
isRepresentative: boolean
@@ -448,6 +469,7 @@ async function generateBiddingNumber(biddingType: string, tx?: any, maxRetries:
// 입찰 생성
export async function createBidding(input: CreateBiddingInput, userId: string) {
try {
+ const userName = await getUserNameById(userId)
return await db.transaction(async (tx) => {
// 자동 입찰번호 생성
const biddingNumber = await generateBiddingNumber(input.biddingType)
@@ -537,8 +559,8 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
managerPhone: input.managerPhone,
remarks: input.remarks,
- createdBy: userId,
- updatedBy: userId,
+ createdBy: userName,
+ updatedBy: userName,
})
.returning({ id: biddings.id })
@@ -589,7 +611,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
title: `사양설명회 - ${file.name}`,
isPublic: false,
isRequired: false,
- uploadedBy: userId,
+ uploadedBy: userName,
})
} else {
console.error(`Failed to save specification meeting file: ${file.name}`, saveResult.error)
@@ -672,7 +694,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
description: `PR ${prItem.prNumber}의 스펙 문서`,
isPublic: false,
isRequired: false,
- uploadedBy: userId,
+ uploadedBy: userName,
displayOrder: fileIndex + 1,
})
} else {
@@ -707,6 +729,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
// 입찰 수정
export async function updateBidding(input: UpdateBiddingInput, userId: string) {
try {
+ const userName = await getUserNameById(userId)
// 존재 여부 확인
const existing = await db
.select({ id: biddings.id })
@@ -750,7 +773,7 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) {
// 업데이트할 데이터 준비
const updateData: any = {
updatedAt: new Date(),
- updatedBy: userId,
+ updatedBy: userName,
}
// 정의된 필드들만 업데이트
@@ -1224,6 +1247,12 @@ export async function getBiddingBasicInfoAction(
// 입찰 조건 조회
export async function getBiddingConditions(biddingId: number) {
try {
+ // biddingId가 유효하지 않은 경우 early return
+ if (!biddingId || isNaN(biddingId) || biddingId <= 0) {
+ console.warn('Invalid biddingId provided to getBiddingConditions:', biddingId)
+ return null
+ }
+
const conditions = await db
.select()
.from(biddingConditions)
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 1dee7adb..13804251 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -286,6 +286,7 @@ export function PrItemsPricingTable({
<TableHead>단위</TableHead>
<TableHead>중량</TableHead>
<TableHead>중량단위</TableHead>
+ <TableHead>SHI 납품요청일</TableHead>
<TableHead>견적단가</TableHead>
<TableHead>견적금액</TableHead>
<TableHead>납품예정일</TableHead>
@@ -328,6 +329,11 @@ export function PrItemsPricingTable({
</TableCell>
<TableCell>{item.weightUnit || '-'}</TableCell>
<TableCell>
+ {item.requestedDeliveryDate ?
+ formatDate(item.requestedDeliveryDate, 'KR') : '-'
+ }
+ </TableCell>
+ <TableCell>
{readOnly ? (
<span className="font-medium">
{quotation.bidUnitPrice.toLocaleString()}
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 8d24ca66..4b316eee 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -60,13 +60,13 @@ interface BiddingDetail {
content: string | null
contractType: string
biddingType: string
- awardCount: string
+ awardCount: string | null
contractPeriod: string | null
- preQuoteDate: string | null
- biddingRegistrationDate: string | null
- submissionStartDate: string | null
- submissionEndDate: string | null
- evaluationDate: string | null
+ preQuoteDate: Date | null
+ biddingRegistrationDate: Date | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ evaluationDate: Date | null
currency: string
budget: number | null
targetPrice: number | null
@@ -78,12 +78,12 @@ interface BiddingDetail {
biddingId: number
invitationStatus: string
finalQuoteAmount: number | null
- finalQuoteSubmittedAt: string | null
+ finalQuoteSubmittedAt: Date | null
isWinner: boolean
isAttendingMeeting: boolean | null
isBiddingParticipated: boolean | null
additionalProposals: string | null
- responseSubmittedAt: string | null
+ responseSubmittedAt: Date | null
}
interface PrItem {
@@ -101,11 +101,11 @@ interface PrItem {
hasSpecDocument: boolean | null
}
-interface PrItemQuotation {
+interface BiddingPrItemQuotation {
prItemId: number
bidUnitPrice: number
bidAmount: number
- proposedDeliveryDate?: string | null
+ proposedDeliveryDate?: string
technicalSpecification?: string
}
@@ -122,7 +122,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
// 품목별 견적 관련 상태
const [prItems, setPrItems] = React.useState<PrItem[]>([])
- const [prItemQuotations, setPrItemQuotations] = React.useState<PrItemQuotation[]>([])
+ const [prItemQuotations, setPrItemQuotations] = React.useState<BiddingPrItemQuotation[]>([])
const [totalQuotationAmount, setTotalQuotationAmount] = React.useState(0)
// 응찰 폼 상태
@@ -169,7 +169,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
prItemId: item.prItemId,
bidUnitPrice: item.bidUnitPrice,
bidAmount: item.bidAmount,
- proposedDeliveryDate: item.proposedDeliveryDate || '',
+ proposedDeliveryDate: item.proposedDeliveryDate || undefined,
technicalSpecification: item.technicalSpecification || undefined
}))
@@ -219,14 +219,43 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (result.success) {
toast({
- title: '성공',
- description: result.message,
+ title: participated ? '참여 확정' : '미참여 확정',
+ description: participated
+ ? '입찰에 참여하셨습니다. 이제 견적을 작성할 수 있습니다.'
+ : '입찰 참여를 거절하셨습니다.',
})
// 데이터 새로고침
const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId)
if (updatedDetail) {
setBiddingDetail(updatedDetail)
+
+ // 참여 확정 시 사전견적 데이터가 있다면 로드
+ if (participated && updatedDetail.biddingCompanyId) {
+ try {
+ const preQuoteData = await getSavedPrItemQuotations(updatedDetail.biddingCompanyId)
+ const convertedQuotations = preQuoteData.map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice,
+ bidAmount: item.bidAmount,
+ proposedDeliveryDate: item.proposedDeliveryDate || undefined,
+ technicalSpecification: item.technicalSpecification || undefined
+ }))
+
+ setPrItemQuotations(convertedQuotations)
+ const total = convertedQuotations.reduce((sum, q) => sum + q.bidAmount, 0)
+ setTotalQuotationAmount(total)
+
+ if (total > 0) {
+ setResponseData(prev => ({
+ ...prev,
+ finalQuoteAmount: total.toString()
+ }))
+ }
+ } catch (error) {
+ console.error('Failed to load pre-quote data after participation:', error)
+ }
+ }
}
} else {
toast({
@@ -248,7 +277,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
}
// 품목별 견적 변경 핸들러
- const handleQuotationsChange = (quotations: PrItemQuotation[]) => {
+ const handleQuotationsChange = (quotations: BiddingPrItemQuotation[]) => {
console.log('견적 변경:', quotations)
setPrItemQuotations(quotations)
}
@@ -282,7 +311,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
prItemId: q.prItemId,
bidUnitPrice: q.bidUnitPrice,
bidAmount: q.bidAmount,
- proposedDeliveryDate: q.proposedDeliveryDate || undefined,
+ proposedDeliveryDate: q.proposedDeliveryDate,
technicalSpecification: q.technicalSpecification
}))
@@ -367,7 +396,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
prItemId: q.prItemId,
bidUnitPrice: q.bidUnitPrice,
bidAmount: q.bidAmount,
- proposedDeliveryDate: q.proposedDeliveryDate || undefined,
+ proposedDeliveryDate: q.proposedDeliveryDate,
technicalSpecification: q.technicalSpecification
})) : undefined,
},
@@ -445,7 +474,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="font-mono">
{biddingDetail.biddingNumber}
- {biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`}
+ {biddingDetail.revision && biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`}
</Badge>
<Badge variant={
biddingDetail.status === 'bidding_disposal' ? 'destructive' :
@@ -460,24 +489,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
{/* 입찰 참여여부 상태 표시 */}
<div className="flex items-center gap-2">
- {biddingDetail.isBiddingParticipated === null ? (
- <div className="flex items-center gap-2">
- <Badge variant="outline">참여 결정 대기</Badge>
- <Button
- onClick={() => handleParticipationDecision(false)}
- disabled={isUpdatingParticipation}
- variant="destructive"
- size="sm"
- >
- <XCircle className="w-4 h-4 mr-1" />
- 미응찰
- </Button>
- </div>
- ) : (
- <Badge variant={biddingDetail.isBiddingParticipated ? 'default' : 'destructive'}>
- {biddingDetail.isBiddingParticipated ? '응찰' : '미응찰'}
- </Badge>
- )}
+ <Badge variant={
+ biddingDetail.isBiddingParticipated === null ? 'outline' :
+ biddingDetail.isBiddingParticipated === true ? 'default' : 'destructive'
+ }>
+ {biddingDetail.isBiddingParticipated === null ? '참여 결정 대기' :
+ biddingDetail.isBiddingParticipated === true ? '응찰' : '미응찰'}
+ </Badge>
</div>
</div>
@@ -516,10 +534,10 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
<div>
<Label className="text-sm font-medium text-muted-foreground">낙찰수</Label>
- <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div>
+ <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : biddingDetail.awardCount === 'multiple' ? '복수' : '미설정'}</div>
</div>
<div>
- <Label className="text-sm font-medium text-muted-foreground">담당자</Label>
+ <Label className="text-sm font-medium text-muted-foreground">입찰 담당자</Label>
<div className="flex items-center gap-2 mt-1">
<User className="w-4 h-4" />
<span>{biddingDetail.managerName || '미설정'}</span>
@@ -527,7 +545,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
</div>
- {biddingDetail.budget && (
+ {/* {biddingDetail.budget && (
<div>
<Label className="text-sm font-medium text-muted-foreground">예산</Label>
<div className="flex items-center gap-2 mt-1">
@@ -535,7 +553,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span>
</div>
</div>
- )}
+ )} */}
{/* 일정 정보 */}
<div className="pt-4 border-t">
@@ -560,33 +578,73 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
{/* 참여 상태에 따른 섹션 표시 */}
{biddingDetail.isBiddingParticipated === false ? (
/* 미응찰 상태 표시 */
- <Card>
- <CardHeader>
+ <Card>
+ <CardHeader>
<CardTitle className="flex items-center gap-2">
<XCircle className="w-5 h-5 text-destructive" />
입찰 참여 거절
</CardTitle>
- </CardHeader>
- <CardContent>
+ </CardHeader>
+ <CardContent>
<div className="text-center py-8">
<XCircle className="w-16 h-16 text-destructive mx-auto mb-4" />
<h3 className="text-lg font-semibold text-destructive mb-2">입찰에 참여하지 않기로 결정했습니다</h3>
<p className="text-muted-foreground">
해당 입찰에 대한 견적 제출 및 관련 기능은 이용할 수 없습니다.
</p>
- </div>
- </CardContent>
- </Card>
- ) : biddingDetail.isBiddingParticipated === true || biddingDetail.isBiddingParticipated === null ? (
+ </div>
+ </CardContent>
+ </Card>
+ ) : biddingDetail.isBiddingParticipated === null ? (
+ /* 참여 의사 확인 섹션 */
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Users className="w-5 h-5" />
+ 입찰 참여 의사 확인
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-center py-8">
+ <div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
+ <Users className="w-8 h-8 text-primary" />
+ </div>
+ <h3 className="text-lg font-semibold mb-2">이 입찰에 참여하시겠습니까?</h3>
+ <p className="text-muted-foreground mb-6">
+ 참여를 선택하시면 견적 작성 및 제출이 가능합니다.
+ </p>
+ <div className="flex justify-center gap-4">
+ <Button
+ onClick={() => handleParticipationDecision(true)}
+ disabled={isUpdatingParticipation}
+ className="min-w-[120px]"
+ >
+ <CheckCircle className="w-4 h-4 mr-2" />
+ 참여하기
+ </Button>
+ <Button
+ onClick={() => handleParticipationDecision(false)}
+ disabled={isUpdatingParticipation}
+ variant="destructive"
+ className="min-w-[120px]"
+ >
+ <XCircle className="w-4 h-4 mr-2" />
+ 미참여
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ ) : biddingDetail.isBiddingParticipated === true ? (
/* 응찰 폼 섹션 */
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Send className="w-5 h-5" />
- 응찰하기
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Send className="w-5 h-5" />
+ 응찰하기
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
{/* 품목별 견적 섹션 */}
{/* <div className="space-y-2">
<Label htmlFor="finalQuoteAmount">총 견적금액 *</Label>
@@ -648,24 +706,22 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
rows={4}
/>
</div> */}
- {/* 응찰 제출 버튼 - 미응찰 상태가 아닐 때만 표시 */}
- {(biddingDetail.isBiddingParticipated === true || biddingDetail.isBiddingParticipated === null) && (
+ {/* 응찰 제출 버튼 - 참여 확정 상태일 때만 표시 */}
<div className="flex justify-end pt-4 gap-2">
<Button
- variant="outline"
- onClick={handleSaveDraft}
- disabled={isSavingDraft || isSubmitting}
- className="min-w-[100px]"
- >
- <Save className="w-4 h-4 mr-2" />
- {isSavingDraft ? '저장 중...' : '임시 저장'}
- </Button>
+ variant="outline"
+ onClick={handleSaveDraft}
+ disabled={isSavingDraft || isSubmitting}
+ className="min-w-[100px]"
+ >
+ <Save className="w-4 h-4 mr-2" />
+ {isSavingDraft ? '저장 중...' : '임시 저장'}
+ </Button>
<Button onClick={handleSubmitResponse} disabled={isSubmitting || isSavingDraft} className="min-w-[100px]">
<Send className="w-4 h-4 mr-2" />
{isSubmitting ? '제출 중...' : '응찰 제출'}
</Button>
</div>
- )}
</CardContent>
</Card>
) : null}
diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
index fdd05154..29a37cae 100644
--- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx
+++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
@@ -381,6 +381,41 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
return
}
+ // 품목별 납품일 검증
+ if (prItemQuotations.length > 0) {
+ for (const quotation of prItemQuotations) {
+ if (!quotation.proposedDeliveryDate?.trim()) {
+ const prItem = prItems.find(item => item.id === quotation.prItemId)
+ toast({
+ title: '유효성 오류',
+ description: `품목 ${prItem?.itemNumber || quotation.prItemId}의 납품예정일을 입력해주세요.`,
+ variant: 'destructive',
+ })
+ return
+ }
+ }
+ }
+
+ const requiredFields = [
+ { value: responseData.proposedContractDeliveryDate, name: '제안 납품일' },
+ { value: responseData.paymentTermsResponse, name: '응답 지급조건' },
+ { value: responseData.taxConditionsResponse, name: '응답 세금조건' },
+ { value: responseData.incotermsResponse, name: '응답 운송조건' },
+ { value: responseData.proposedShippingPort, name: '제안 선적지' },
+ { value: responseData.proposedDestinationPort, name: '제안 도착지' },
+ { value: responseData.sparePartResponse, name: '스페어파트 응답' },
+ ]
+
+ const missingField = requiredFields.find(field => !field.value?.trim())
+ if (missingField) {
+ toast({
+ title: '유효성 오류',
+ description: `${missingField.name}을(를) 입력해주세요.`,
+ variant: 'destructive',
+ })
+ return
+ }
+
startTransition(async () => {
const submissionData = {
preQuoteAmount: totalAmount, // 품목별 계산된 총 금액 사용
@@ -873,7 +908,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
{/* 총 금액 표시 (읽기 전용) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
- <Label htmlFor="totalAmount">총 사전견적 금액 *</Label>
+ <Label htmlFor="totalAmount">총 사전견적 금액 <span className="text-red-500">*</span></Label>
<Input
id="totalAmount"
type="text"
@@ -887,7 +922,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
</div>
<div className="space-y-2">
- <Label htmlFor="proposedContractDeliveryDate">제안 납품일</Label>
+ <Label htmlFor="proposedContractDeliveryDate">제안 납품일 <span className="text-red-500">*</span></Label>
<Input
id="proposedContractDeliveryDate"
type="date"
@@ -905,7 +940,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
- <Label htmlFor="paymentTermsResponse">응답 지급조건</Label>
+ <Label htmlFor="paymentTermsResponse">응답 지급조건 <span className="text-red-500">*</span></Label>
<Input
id="paymentTermsResponse"
value={responseData.paymentTermsResponse}
@@ -915,7 +950,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
</div>
<div className="space-y-2">
- <Label htmlFor="taxConditionsResponse">응답 세금조건</Label>
+ <Label htmlFor="taxConditionsResponse">응답 세금조건 <span className="text-red-500">*</span></Label>
<Input
id="taxConditionsResponse"
value={responseData.taxConditionsResponse}
@@ -927,7 +962,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
- <Label htmlFor="incotermsResponse">응답 운송조건</Label>
+ <Label htmlFor="incotermsResponse">응답 운송조건 <span className="text-red-500">*</span></Label>
<Input
id="incotermsResponse"
value={responseData.incotermsResponse}
@@ -937,7 +972,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
</div>
<div className="space-y-2">
- <Label htmlFor="proposedShippingPort">제안 선적지</Label>
+ <Label htmlFor="proposedShippingPort">제안 선적지 <span className="text-red-500">*</span></Label>
<Input
id="proposedShippingPort"
value={responseData.proposedShippingPort}
@@ -949,7 +984,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
- <Label htmlFor="proposedDestinationPort">제안 도착지</Label>
+ <Label htmlFor="proposedDestinationPort">제안 도착지 <span className="text-red-500">*</span></Label>
<Input
id="proposedDestinationPort"
value={responseData.proposedDestinationPort}
@@ -959,7 +994,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
</div>
<div className="space-y-2">
- <Label htmlFor="sparePartResponse">스페어파트 응답</Label>
+ <Label htmlFor="sparePartResponse">스페어파트 응답 <span className="text-red-500">*</span></Label>
<Input
id="sparePartResponse"
value={responseData.sparePartResponse}