summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/actions.ts172
-rw-r--r--lib/bidding/detail/service.ts69
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx2
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx8
-rw-r--r--lib/bidding/detail/table/bidding-invitation-dialog.tsx98
-rw-r--r--lib/bidding/list/biddings-page-header.tsx4
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx21
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx6
-rw-r--r--lib/bidding/list/biddings-table.tsx17
-rw-r--r--lib/bidding/list/biddings-transmission-dialog.tsx14
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx249
-rw-r--r--lib/bidding/list/edit-bidding-sheet.tsx37
-rw-r--r--lib/bidding/pre-quote/service.ts16
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx2
-rw-r--r--lib/bidding/service.ts255
-rw-r--r--lib/bidding/validation.ts10
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx5
-rw-r--r--lib/bidding/vendor/components/simple-file-upload.tsx3
-rw-r--r--lib/bidding/vendor/partners-bidding-attachments-dialog.tsx1
-rw-r--r--lib/bidding/vendor/partners-bidding-attendance-dialog.tsx19
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx68
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx55
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx132
-rw-r--r--lib/bidding/vendor/partners-bidding-participation-dialog.tsx248
-rw-r--r--lib/bidding/vendor/partners-bidding-pre-quote.tsx178
-rw-r--r--lib/bidding/vendor/partners-bidding-toolbar-actions.tsx16
26 files changed, 832 insertions, 873 deletions
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index 9aabd469..65ff3138 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -1,26 +1,24 @@
"use server"
import db from "@/db/db"
-import { eq, and } from "drizzle-orm"
+import { eq, and, sql } from "drizzle-orm"
import {
biddings,
biddingCompanies,
prItemsForBidding,
vendors,
generalContracts,
- generalContractItems
+ generalContractItems,
+ biddingConditions
} from "@/db/schema"
import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po"
import { getCurrentSAPDate } from "@/lib/soap/utils"
+import { generateContractNumber } from "@/lib/general-contracts/service"
-// TO Contract 서버 액션
+// TO Contract
export async function transmitToContract(biddingId: number, userId: number) {
- console.log('=== transmitToContract STARTED ===')
- console.log('biddingId:', biddingId, 'userId:', userId)
-
try {
// 1. 입찰 정보 조회 (단순 쿼리)
- console.log('Querying bidding...')
const bidding = await db.select()
.from(biddings)
.where(eq(biddings.id, biddingId))
@@ -31,14 +29,19 @@ export async function transmitToContract(biddingId: number, userId: number) {
}
const biddingData = bidding[0]
- console.log('biddingData', biddingData)
- // 2. 낙찰된 업체들 조회 (별도 쿼리)
- console.log('Querying bidding companies...')
- let winnerCompaniesData = []
+ // 2. 입찰 조건 정보 조회
+ const biddingConditionData = await db.select()
+ .from(biddingConditions)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ .limit(1)
+
+ const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null
+
+ // 3. 낙찰된 업체들 조회 (별도 쿼리)
+ let winnerCompaniesData: { companyId: number; finalQuoteAmount: string | null; vendorCode: string | null; vendorName: string | null; }[] = []
try {
// 2.1 biddingCompanies만 먼저 조회 (join 제거)
- console.log('Step 1: Querying biddingCompanies only...')
const biddingCompaniesRaw = await db.select()
.from(biddingCompanies)
.where(
@@ -48,12 +51,9 @@ export async function transmitToContract(biddingId: number, userId: number) {
)
)
- console.log('biddingCompaniesRaw:', biddingCompaniesRaw)
// 2.2 각 company에 대한 vendor 정보 개별 조회
for (const bc of biddingCompaniesRaw) {
- console.log('Processing companyId:', bc.companyId)
-
try {
const vendorData = await db.select()
.from(vendors)
@@ -61,13 +61,11 @@ export async function transmitToContract(biddingId: number, userId: number) {
.limit(1)
const vendor = vendorData.length > 0 ? vendorData[0] : null
- console.log('Vendor data for', bc.companyId, ':', vendor)
-
winnerCompaniesData.push({
companyId: bc.companyId,
finalQuoteAmount: bc.finalQuoteAmount,
vendorCode: vendor?.vendorCode || null,
- vendorName: vendor?.vendorName || null,
+ vendorName: vendor?.vendorName || null as string | null,
})
} catch (vendorError) {
console.error('Vendor query error for', bc.companyId, ':', vendorError)
@@ -75,22 +73,18 @@ export async function transmitToContract(biddingId: number, userId: number) {
winnerCompaniesData.push({
companyId: bc.companyId,
finalQuoteAmount: bc.finalQuoteAmount,
- vendorCode: null,
- vendorName: null,
+ vendorCode: null as string | null,
+ vendorName: null as string | null,
})
}
}
- console.log('winnerCompaniesData type:', typeof winnerCompaniesData)
- console.log('winnerCompaniesData length:', winnerCompaniesData?.length)
- console.log('winnerCompaniesData:', winnerCompaniesData)
} catch (queryError) {
console.error('Query error:', queryError)
throw new Error(`biddingCompanies 쿼리 실패: ${queryError}`)
}
// 상태 검증
- console.log('biddingData.status', biddingData.status)
if (biddingData.status !== 'vendor_selected') {
throw new Error("업체 선정이 완료되지 않은 입찰입니다.")
}
@@ -100,32 +94,64 @@ export async function transmitToContract(biddingId: number, userId: number) {
throw new Error("낙찰된 업체가 없습니다.")
}
- console.log('Processing', winnerCompaniesData.length, 'winner companies')
for (const winnerCompany of winnerCompaniesData) {
- // 계약 번호 자동 생성 (현재 시간 기반)
- const contractNumber = `CONTRACT-BID-${Date.now()}-${winnerCompany.companyId}`
- console.log('contractNumber', contractNumber)
+ // 계약 번호 자동 생성 (실제 규칙에 맞게)
+ const contractNumber = await generateContractNumber(userId.toString(), biddingData.contractType)
+ console.log('Generated contractNumber:', contractNumber)
// general-contract 생성
const contractResult = await db.insert(generalContracts).values({
contractNumber,
revision: 0,
contractSourceType: 'bid', // 입찰에서 생성됨
status: 'Draft',
- category: biddingData.contractType as any, // 단가계약, 일반계약, 매각계약
+ category: biddingData.contractType || 'general',
name: biddingData.title,
- selectionMethod: '입찰',
vendorId: winnerCompany.companyId,
linkedBidNumber: biddingData.biddingNumber,
- contractAmount: winnerCompany.finalQuoteAmount || undefined,
+ contractAmount: winnerCompany.finalQuoteAmount || null,
+ contractStartDate: biddingData.contractStartDate || null,
+ contractEndDate: biddingData.contractEndDate || null,
currency: biddingData.currency || 'KRW',
- registeredById: userId, // TODO: 현재 사용자 ID로 변경 필요
- lastUpdatedById: userId, // TODO: 현재 사용자 ID로 변경 필요
+ // 계약 조건 정보 추가
+ paymentTerm: biddingCondition?.paymentTerms || null,
+ taxType: biddingCondition?.taxConditions || 'V0',
+ deliveryTerm: biddingCondition?.incoterms || 'FOB',
+ shippingLocation: biddingCondition?.shippingPort || null,
+ dischargeLocation: biddingCondition?.destinationPort || null,
+ registeredById: userId,
+ lastUpdatedById: userId,
}).returning({ id: generalContracts.id })
console.log('contractResult', contractResult)
const contractId = contractResult[0].id
- // 3. PR 아이템들로 general-contract-items 생성 (일단 생략)
- console.log('Skipping PR items creation for now')
+ // 4. PR 아이템들로 general-contract-items 생성
+ const prItems = await db.select()
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ if (prItems.length > 0) {
+ console.log(`Creating ${prItems.length} contract items for contract ${contractId}`)
+ for (const prItem of prItems) {
+ await db.insert(generalContractItems).values({
+ contractId,
+ project: prItem.projectInfo || '',
+ itemCode: prItem.itemNumber || '',
+ itemInfo: prItem.itemInfo || '',
+ specification: prItem.materialDescription || '',
+ quantity: prItem.quantity || null,
+ quantityUnit: prItem.quantityUnit || '',
+ contractUnitPrice: prItem.annualUnitPrice || null,
+ contractAmount: prItem.annualUnitPrice && prItem.quantity
+ ? (prItem.annualUnitPrice * prItem.quantity)
+ : null,
+ contractCurrency: biddingData.currency || 'KRW',
+ contractDeliveryDate: prItem.requestedDeliveryDate || null,
+ })
+ }
+ console.log(`Created ${prItems.length} contract items`)
+ } else {
+ console.log('No PR items found for this bidding')
+ }
}
return { success: true, message: `${winnerCompaniesData.length}개의 계약서가 생성되었습니다.` }
@@ -136,59 +162,80 @@ export async function transmitToContract(biddingId: number, userId: number) {
}
}
-// TO PO 서버 액션
+// TO PO
export async function transmitToPO(biddingId: number) {
try {
- // 1. 입찰 정보 및 낙찰 업체 조회
- const bidding = await db.query.biddings.findFirst({
- where: eq(biddings.id, biddingId),
- with: {
- biddingCompanies: {
- where: eq(biddingCompanies.isWinner, true), // 낙찰된 업체만
- with: {
- vendor: true
- }
- },
- prItemsForBidding: true
- }
- })
+ // 1. 입찰 정보 조회
+ const biddingData = await db.select()
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
- if (!bidding) {
+ if (!biddingData || biddingData.length === 0) {
throw new Error("입찰 정보를 찾을 수 없습니다.")
}
+ const bidding = biddingData[0]
+
if (bidding.status !== 'vendor_selected') {
throw new Error("업체 선정이 완료되지 않은 입찰입니다.")
}
- const winnerCompanies = bidding.biddingCompanies.filter(bc => bc.isWinner)
+ // 2. 입찰 조건 정보 조회
+ const biddingConditionData = await db.select()
+ .from(biddingConditions)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ .limit(1)
+
+ const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null
- if (winnerCompanies.length === 0) {
+ // 3. 낙찰된 업체들 조회
+ const winnerCompaniesRaw = await db.select({
+ companyId: biddingCompanies.companyId,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isWinner, true)
+ )
+ )
+
+ if (winnerCompaniesRaw.length === 0) {
throw new Error("낙찰된 업체가 없습니다.")
}
- // 2. PO 데이터 구성
+ // 4. PR 아이템 조회
+ const prItems = await db.select()
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ // 5. PO 데이터 구성 (bidding condition 정보 사용)
const poData = {
- T_Bidding_HEADER: winnerCompanies.map((company, index) => ({
+ T_Bidding_HEADER: winnerCompaniesRaw.map((company, index) => ({
ANFNR: bidding.biddingNumber,
- LIFNR: company.vendor?.vendorCode || `VENDOR${company.companyId}`,
+ LIFNR: company.vendorCode || `VENDOR${company.companyId}`,
ZPROC_IND: 'A', // 구매 처리 상태
ANGNR: bidding.biddingNumber,
WAERS: bidding.currency || 'KRW',
- ZTERM: '0001', // 기본 지급조건
- INCO1: 'FOB',
- INCO2: 'Seoul, Korea',
- MWSKZ: 'V0', // 세금 코드
+ ZTERM: biddingCondition?.paymentTerms || '0001', // 지급조건
+ INCO1: biddingCondition?.incoterms || 'FOB', // Incoterms
+ INCO2: biddingCondition?.destinationPort || biddingCondition?.shippingPort || 'Seoul, Korea',
+ MWSKZ: biddingCondition?.taxConditions || 'V0', // 세금 코드
LANDS: 'KR',
ZRCV_DT: getCurrentSAPDate(),
ZATTEN_IND: 'Y',
IHRAN: getCurrentSAPDate(),
TEXT: `PO from Bidding: ${bidding.title}`,
})),
- T_Bidding_ITEM: bidding.prItemsForBidding?.map((item, index) => ({
+ T_Bidding_ITEM: prItems.map((item, index) => ({
ANFNR: bidding.biddingNumber,
ANFPS: (index + 1).toString().padStart(5, '0'),
- LIFNR: winnerCompanies[0]?.vendor?.vendorCode || `VENDOR${winnerCompanies[0]?.companyId}`,
+ LIFNR: winnerCompaniesRaw[0]?.vendorCode || `VENDOR${winnerCompaniesRaw[0]?.companyId}`,
NETPR: item.annualUnitPrice?.toString() || '0',
PEINH: '1',
BPRME: item.quantityUnit || 'EA',
@@ -199,7 +246,7 @@ export async function transmitToPO(biddingId: number) {
? ((item.annualUnitPrice * item.quantity) * 1.1).toString() // 10% 부가세 가정
: '0',
LFDAT: item.requestedDeliveryDate?.toISOString().split('T')[0] || getCurrentSAPDate(),
- })) || [],
+ })),
T_PR_RETURN: [{
ANFNR: bidding.biddingNumber,
ANFPS: '00001',
@@ -211,6 +258,7 @@ export async function transmitToPO(biddingId: number) {
}
// 3. SAP으로 PO 전송
+ console.log('SAP으로 PO 전송할 poData', poData)
const result = await createPurchaseOrder(poData)
if (!result.success) {
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index b00a4f4f..a603834c 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -969,7 +969,7 @@ 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,
@@ -1118,18 +1118,18 @@ export async function createRebidding(biddingId: number, userId: string) {
return { success: false, error: '재입찰 업데이트에 실패했습니다.' }
}
- // 참여 업체들의 상태를 대기로 변경
- await db
- .update(biddingCompanies)
- .set({
- isBiddingParticipated: null, // 대기 상태로 변경
- invitationStatus: 'sent',
- updatedAt: new Date()
- })
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isBiddingParticipated, true)
- ))
+ // // 참여 업체들의 상태를 대기로 변경
+ // await db
+ // .update(biddingCompanies)
+ // .set({
+ // isBiddingParticipated: null, // 대기 상태로 변경
+ // invitationStatus: 'sent',
+ // updatedAt: new Date()
+ // })
+ // .where(and(
+ // eq(biddingCompanies.biddingId, biddingId),
+ // eq(biddingCompanies.isBiddingParticipated, true)
+ // ))
// 재입찰 안내 메일 발송
for (const company of participantCompanies) {
@@ -1686,6 +1686,7 @@ export interface PartnersBiddingListItem {
isAttendingMeeting: boolean | null
isPreQuoteSelected: boolean | null
isPreQuoteParticipated: boolean | null
+ isBiddingParticipated: boolean | null
preQuoteDeadline: Date | null
isBiddingInvited: boolean | null
notes: string | null
@@ -1702,7 +1703,9 @@ export interface PartnersBiddingListItem {
title: string
contractType: string
biddingType: string
- contractPeriod: string | null
+ preQuoteDate: Date | null
+ contractStartDate: Date | null
+ contractEndDate: Date | null
submissionStartDate: Date | null
submissionEndDate: Date | null
status: string
@@ -1733,6 +1736,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
isAttendingMeeting: biddingCompanies.isAttendingMeeting,
isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
preQuoteDeadline: biddingCompanies.preQuoteDeadline,
isBiddingInvited: biddingCompanies.isBiddingInvited,
notes: biddingCompanies.notes,
@@ -1749,7 +1753,9 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
title: biddings.title,
contractType: biddings.contractType,
biddingType: biddings.biddingType,
- contractPeriod: biddings.contractPeriod,
+ preQuoteDate: biddings.preQuoteDate,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
submissionStartDate: biddings.submissionStartDate,
submissionEndDate: biddings.submissionEndDate,
status: biddings.status,
@@ -1810,7 +1816,9 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
contractType: biddings.contractType,
biddingType: biddings.biddingType,
awardCount: biddings.awardCount,
- contractPeriod: biddings.contractPeriod,
+ preQuoteDate: biddings.preQuoteDate,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
// 일정 정보
preQuoteDate: biddings.preQuoteDate,
@@ -1841,6 +1849,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
isBiddingParticipated: biddingCompanies.isBiddingParticipated,
isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isBiddingParticipated: biddingCompanies.isBiddingParticipated,
hasSpecificationMeeting: biddings.hasSpecificationMeeting,
// 응답한 조건들 (company_condition_responses) - 제시된 조건과 응답 모두 여기서 관리
paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
@@ -2640,3 +2649,31 @@ export async function getVendorPricesForBidding(biddingId: number) {
}
)()
}
+
+// 사양설명회 참여 여부 업데이트
+export async function setSpecificationMeetingParticipation(biddingCompanyId: number, participated: boolean) {
+ try {
+ const result = await db.update(biddingCompanies)
+ .set({
+ isAttendingMeeting: participated,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ .returning({ biddingId: biddingCompanies.biddingId })
+
+ if (result.length > 0) {
+ const biddingId = result[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('quotation-vendors')
+ revalidatePath(`/partners/bid/${biddingId}`)
+ }
+
+ return {
+ success: true,
+ message: `사양설명회 참여상태가 ${participated ? '참여' : '불참'}로 업데이트되었습니다.`,
+ }
+ } catch (error) {
+ console.error('Failed to update specification meeting participation:', error)
+ return { success: false, error: '사양설명회 참여상태 업데이트에 실패했습니다.' }
+ }
+} \ No newline at end of file
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index cbdf79c2..3b42cc88 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -142,7 +142,7 @@ export function getBiddingDetailVendorColumns({
status === 'rejected' ? 'destructive' : 'outline'
const label = status === 'selected' ? '선정' :
- status === 'submitted' ? '제출' :
+ status === 'submitted' ? '견적 제출' :
status === 'rejected' ? '거절' : '대기'
return <Badge variant={variant}>{label}</Badge>
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 893fb185..eec44bb1 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -68,6 +68,14 @@ export function BiddingDetailVendorToolbarActions({
}
const handleRegister = () => {
+ if (bidding.status !== 'set_target_price') {
+ toast({
+ title: '오류',
+ description: '내정가 산정이 완료되어야 입찰 등록을 할 수 있습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
// 본입찰 초대 다이얼로그 열기
setIsBiddingInvitationDialogOpen(true)
}
diff --git a/lib/bidding/detail/table/bidding-invitation-dialog.tsx b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
index 031231a1..48b235f9 100644
--- a/lib/bidding/detail/table/bidding-invitation-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
@@ -159,7 +159,7 @@ export function BiddingInvitationDialog({
try {
const [contractsResult, templatesData] = await Promise.all([
getSelectedVendorsForBidding(biddingId),
- getActiveContractTemplates()
+ getActiveContractTemplates(),
]);
// 기존 계약 조회 (사전견적에서 보낸 기본계약 확인) - 서버 액션 사용
@@ -184,7 +184,6 @@ export function BiddingInvitationDialog({
checked: false
}));
setSelectedContracts(initialSelected);
-
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
toast({
@@ -318,36 +317,34 @@ export function BiddingInvitationDialog({
const handleSendInvitation = () => {
const selectedContractTemplates = selectedContracts.filter(c => c.checked);
- if (selectedContractTemplates.length === 0) {
- toast({
- title: '알림',
- description: '발송할 기본계약서를 선택해주세요.',
- variant: 'default',
- })
- return
- }
-
startTransition(async () => {
try {
- // 선택된 템플릿에 따라 PDF 생성
- setIsGeneratingPdfs(true)
- setPdfGenerationProgress(0)
+ let generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }> = []
const generatedPdfsMap = new Map<string, { buffer: number[], fileName: string }>()
- let generatedCount = 0;
- for (const vendor of selectedVendors) {
- // 사전견적에서 이미 기본계약을 보낸 벤더인지 확인
- const hasExistingContract = existingContracts.some((ec: any) =>
- ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
- );
-
- if (hasExistingContract) {
- console.log(`벤더 ${vendor.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`);
- generatedCount++;
- setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100);
- continue;
- }
+ // 선택된 템플릿이 있는 경우에만 PDF 생성
+ if (selectedContractTemplates.length > 0) {
+ setIsGeneratingPdfs(true)
+ setPdfGenerationProgress(0)
+
+ let generatedCount = 0;
+ for (const vendor of selectedVendors) {
+ // 사전견적에서 이미 기본계약을 보낸 벤더인지 확인
+ const hasExistingContract = existingContracts.some((ec: any) =>
+ ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
+ );
+
+ if (hasExistingContract) {
+ console.log(`벤더 ${vendor.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`);
+ generatedCount++;
+ setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100);
+ continue;
+ }
for (const contract of selectedContractTemplates) {
setCurrentGeneratingContract(`${vendor.vendorName} - ${contract.templateName}`);
@@ -374,7 +371,16 @@ export function BiddingInvitationDialog({
setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100);
}
- setIsGeneratingPdfs(false);
+ setIsGeneratingPdfs(false);
+
+ const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({
+ key,
+ buffer: data.buffer,
+ fileName: data.fileName,
+ }));
+
+ generatedPdfs = pdfsArray;
+ }
const vendorData = selectedVendors.map(vendor => {
const hasExistingContract = existingContracts.some((ec: any) =>
@@ -400,15 +406,9 @@ export function BiddingInvitationDialog({
};
});
- const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({
- key,
- buffer: data.buffer,
- fileName: data.fileName,
- }));
-
await onSend({
vendors: vendorData,
- generatedPdfs: pdfsArray,
+ generatedPdfs: generatedPdfs,
message: additionalMessage
});
@@ -428,7 +428,7 @@ export function BiddingInvitationDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col">
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col" style={{width:900, maxWidth:900}}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
@@ -448,7 +448,7 @@ export function BiddingInvitationDialog({
<AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle>
<AlertDescription className="text-orange-700">
사전견적에서 이미 기본계약을 받은 업체가 있습니다.
- 해당 업체들은 계약서 재생성을 건너뜁니다.
+ 해당 업체들은 계약서 재생성을 건너뜁니다. (본입찰 초대는 정상 진행됩니다)
</AlertDescription>
</Alert>
)}
@@ -494,18 +494,21 @@ export function BiddingInvitationDialog({
<div>
<h4 className="text-sm font-medium text-orange-700 mb-2 flex items-center gap-2">
<X className="h-4 w-4 text-orange-600" />
- 기존 계약 존재 (건너뜀) ({vendorsWithExistingContracts.length}개)
+ 기존 계약 존재 (계약서 재생성 건너뜀) ({vendorsWithExistingContracts.length}개)
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{vendorsWithExistingContracts.map((vendor) => (
- <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-orange-50 rounded border border-orange-200 opacity-75">
+ <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-orange-50 rounded border border-orange-200">
<X className="h-4 w-4 text-orange-600" />
- <span className="text-muted-foreground">{vendor.vendorName}</span>
+ <span className="font-medium">{vendor.vendorName}</span>
<Badge variant="outline" className="text-xs">
{vendor.vendorCode}
</Badge>
- <Badge variant="secondary" className="text-xs">
- 계약 존재
+ <Badge variant="secondary" className="text-xs bg-orange-100 text-orange-800">
+ 계약 존재 (재생성 건너뜀)
+ </Badge>
+ <Badge variant="outline" className="text-xs border-green-500 text-green-700">
+ 본입찰 초대
</Badge>
</div>
))}
@@ -522,7 +525,7 @@ export function BiddingInvitationDialog({
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-5 w-5 text-blue-600" />
- 기본계약 선택
+ 기본계약 선택 (선택사항)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -657,7 +660,7 @@ export function BiddingInvitationDialog({
</Button>
<Button
onClick={handleSendInvitation}
- disabled={isPending || selectedContractCount === 0 || isGeneratingPdfs}
+ disabled={isPending || selectedVendors.length === 0 || isGeneratingPdfs}
className="w-full sm:w-auto"
>
{isGeneratingPdfs ? (
@@ -678,13 +681,6 @@ export function BiddingInvitationDialog({
)}
</Button>
</div>
- {/* {(selectedContractCount > 0) && (
- <div className="mt-4 sm:mt-0 text-sm text-muted-foreground">
- <p>
- {selectedVendors.length}개 업체에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다.
- </p>
- </div>
- )} */}
</DialogFooter>
</DialogContent>
</Dialog>
diff --git a/lib/bidding/list/biddings-page-header.tsx b/lib/bidding/list/biddings-page-header.tsx
index 7fa9a39c..64e588c3 100644
--- a/lib/bidding/list/biddings-page-header.tsx
+++ b/lib/bidding/list/biddings-page-header.tsx
@@ -19,13 +19,13 @@ export function BiddingsPageHeader() {
{/* 우측: 액션 버튼들 */}
<div className="flex items-center gap-2">
- <Button
+ {/* <Button
variant="outline"
onClick={() => router.push('/evcp/biddings/analytics')}
>
<TrendingUp className="mr-2 h-4 w-4" />
분석 보기
- </Button>
+ </Button> */}
<Button
variant="outline"
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index 5240b134..7f0b8e40 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -293,12 +293,23 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
},
{
- accessorKey: "contractPeriod",
+ id: "contractPeriod",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />,
- cell: ({ row }) => (
- <span className="truncate max-w-[100px]">{row.original.contractPeriod || '-'}</span>
- ),
- size: 100,
+ cell: ({ row }) => {
+ const startDate = row.original.contractStartDate
+ const endDate = row.original.contractEndDate
+
+ if (!startDate || !endDate) {
+ return <span className="text-muted-foreground">-</span>
+ }
+
+ return (
+ <div className="text-xs max-w-[120px] truncate" title={`${formatDate(startDate, "KR")} ~ ${formatDate(endDate, "KR")}`}>
+ {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ </div>
+ )
+ },
+ size: 120,
meta: { excelHeader: "계약기간" },
},
]
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index ed5538c6..702396ae 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -23,11 +23,9 @@ import { TransmissionDialog } from "./biddings-transmission-dialog"
interface BiddingsTableToolbarActionsProps {
table: Table<BiddingListItem>
- paymentTermsOptions: Array<{code: string, description: string}>
- incotermsOptions: Array<{code: string, description: string}>
}
-export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incotermsOptions }: BiddingsTableToolbarActionsProps) {
+export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActionsProps) {
const router = useRouter()
const { data: session } = useSession()
const [isExporting, setIsExporting] = React.useState(false)
@@ -66,8 +64,6 @@ export function BiddingsTableToolbarActions({ table, paymentTermsOptions, incote
<div className="flex items-center gap-2">
{/* 신규 생성 */}
<CreateBiddingDialog
- paymentTermsOptions={paymentTermsOptions}
- incotermsOptions={incotermsOptions}
/>
{/* 전송하기 (업체선정 완료된 입찰만) */}
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index 2a8f98c3..2ecfaa73 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -12,7 +12,7 @@ import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { getBiddingsColumns } from "./biddings-table-columns"
-import { getBiddings, getBiddingStatusCounts, getActivePaymentTerms, getActiveIncoterms, getBiddingTypeCounts, getBiddingManagerCounts, getBiddingMonthlyStats } from "@/lib/bidding/service"
+import { getBiddings, getBiddingStatusCounts } from "@/lib/bidding/service"
import { BiddingListItem } from "@/db/schema"
import { BiddingsTableToolbarActions } from "./biddings-table-toolbar-actions"
import {
@@ -28,26 +28,17 @@ interface BiddingsTableProps {
promises: Promise<
[
Awaited<ReturnType<typeof getBiddings>>,
- Awaited<ReturnType<typeof getBiddingStatusCounts>>,
- Awaited<ReturnType<typeof getBiddingTypeCounts>>, // 추가
- Awaited<ReturnType<typeof getBiddingManagerCounts>>, // 추가
- Awaited<ReturnType<typeof getBiddingMonthlyStats>>, // 추가
- Awaited<ReturnType<typeof getActivePaymentTerms>>,
- Awaited<ReturnType<typeof getActiveIncoterms>>
+ Awaited<ReturnType<typeof getBiddingStatusCounts>>
]
>
}
export function BiddingsTable({ promises }: BiddingsTableProps) {
- const [biddingsResult, statusCounts, typeCounts, managerCounts, monthlyStats, paymentTermsResult, incotermsResult] = React.use(promises)
+ const [biddingsResult, statusCounts] = React.use(promises)
// biddingsResult에서 data와 pageCount 추출
const { data, pageCount } = biddingsResult
- const paymentTermsOptions = paymentTermsResult.success && 'data' in paymentTermsResult ? paymentTermsResult.data || [] : []
- const incotermsOptions = incotermsResult.success && 'data' in incotermsResult ? incotermsResult.data || [] : []
- console.log(paymentTermsOptions,"paymentTermsOptions")
- console.log(incotermsOptions,"incotermsOptions")
const [isCompact, setIsCompact] = React.useState<boolean>(false)
const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
@@ -179,8 +170,6 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
>
<BiddingsTableToolbarActions
table={table}
- paymentTermsOptions={paymentTermsOptions}
- incotermsOptions={incotermsOptions}
/>
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/bidding/list/biddings-transmission-dialog.tsx b/lib/bidding/list/biddings-transmission-dialog.tsx
index d307ec9d..035ab583 100644
--- a/lib/bidding/list/biddings-transmission-dialog.tsx
+++ b/lib/bidding/list/biddings-transmission-dialog.tsx
@@ -19,10 +19,6 @@ import { Label } from "@/components/ui/label"
import { BiddingListItem } from "@/db/schema"
import { transmitToContract, transmitToPO } from "@/lib/bidding/actions"
-console.log('=== Module loaded ===')
-console.log('transmitToContract imported:', typeof transmitToContract)
-console.log('transmitToPO imported:', typeof transmitToPO)
-
interface TransmissionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -38,23 +34,15 @@ export function TransmissionDialog({ open, onOpenChange, bidding, userId }: Tran
const handleToContract = async () => {
try {
setIsLoading(true)
- console.log('=== START handleToContract ===')
console.log('bidding.id', bidding.id)
console.log('userId', userId)
- console.log('transmitToContract function:', typeof transmitToContract)
-
- console.log('About to call transmitToContract...')
- const result = await transmitToContract(bidding.id, userId)
- console.log('transmitToContract result:', result)
-
+ await transmitToContract(bidding.id, userId)
toast.success('계약서 생성이 완료되었습니다.')
onOpenChange(false)
} catch (error) {
- console.error('handleToContract error:', error)
toast.error(`계약서 생성에 실패했습니다: ${error}`)
} finally {
setIsLoading(false)
- console.log('=== END handleToContract ===')
}
}
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index 4fc4fd7b..a25dd363 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -67,7 +67,8 @@ import {
} from "@/components/ui/file-list"
import { Checkbox } from "@/components/ui/checkbox"
-import { createBidding, type CreateBiddingInput, getActivePaymentTerms, getActiveIncoterms } from "@/lib/bidding/service"
+import { createBidding, type CreateBiddingInput } from "@/lib/bidding/service"
+import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service"
import {
createBiddingSchema,
type CreateBiddingSchema
@@ -127,12 +128,7 @@ interface PRItemInfo {
const TAB_ORDER = ["basic", "contract", "schedule", "conditions", "details", "manager"] as const
type TabType = typeof TAB_ORDER[number]
-interface CreateBiddingDialogProps {
- paymentTermsOptions?: Array<{code: string, description: string}>
- incotermsOptions?: Array<{code: string, description: string}>
-}
-
-export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions = [] }: CreateBiddingDialogProps) {
+export function CreateBiddingDialog() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = React.useState(false)
const { data: session } = useSession()
@@ -141,6 +137,13 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // 추가
const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null) // 추가
+ // Procurement 데이터 상태들
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{code: string, description: string}>>([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<Array<{code: string, description: string}>>([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{code: string, description: string}>>([])
+ const [procurementLoading, setProcurementLoading] = React.useState(false)
+
// 사양설명회 정보 상태
const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({
meetingDate: "",
@@ -157,8 +160,24 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
meetingFiles: [], // 사양설명회 첨부파일
})
- // PR 아이템들 상태
- const [prItems, setPrItems] = React.useState<PRItemInfo[]>([])
+ // PR 아이템들 상태 - 기본적으로 하나의 빈 아이템 생성
+ const [prItems, setPrItems] = React.useState<PRItemInfo[]>([
+ {
+ id: `pr-default`,
+ prNumber: "",
+ itemCode: "",
+ itemInfo: "",
+ quantity: "",
+ quantityUnit: "EA",
+ totalWeight: "",
+ weightUnit: "KG",
+ materialDescription: "",
+ hasSpecDocument: false,
+ requestedDeliveryDate: "",
+ specFiles: [],
+ isRepresentative: true, // 첫 번째 아이템은 대표 아이템
+ }
+ ])
// 파일 첨부를 위해 선택된 아이템 ID
const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
@@ -175,6 +194,69 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
sparePartOptions: "",
})
+ // Procurement 데이터 로드 함수들
+ const loadPaymentTerms = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getPaymentTermsForSelection();
+ setPaymentTermsOptions(data);
+ } catch (error) {
+ console.error("Failed to load payment terms:", error);
+ toast.error("결제조건 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ const loadIncoterms = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getIncotermsForSelection();
+ setIncotermsOptions(data);
+ } catch (error) {
+ console.error("Failed to load incoterms:", error);
+ toast.error("운송조건 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ const loadShippingPlaces = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getPlaceOfShippingForSelection();
+ setShippingPlaces(data);
+ } catch (error) {
+ console.error("Failed to load shipping places:", error);
+ toast.error("선적지 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getPlaceOfDestinationForSelection();
+ setDestinationPlaces(data);
+ } catch (error) {
+ console.error("Failed to load destination places:", error);
+ toast.error("하역지 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ // 다이얼로그 열릴 때 procurement 데이터 로드
+ React.useEffect(() => {
+ if (open) {
+ loadPaymentTerms();
+ loadIncoterms();
+ loadShippingPlaces();
+ loadDestinationPlaces();
+ }
+ }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
+
// 사양설명회 파일 추가
const addMeetingFiles = (files: File[]) => {
@@ -211,7 +293,8 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
contractType: "general",
biddingType: "equipment",
awardCount: "single",
- contractPeriod: "",
+ contractStartDate: "",
+ contractEndDate: "",
submissionStartDate: "",
submissionEndDate: "",
@@ -268,9 +351,10 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
isValid: formValues.contractType &&
formValues.biddingType &&
formValues.awardCount &&
- formValues.contractPeriod.trim() !== "" &&
+ formValues.contractStartDate &&
+ formValues.contractEndDate &&
formValues.currency,
- hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractPeriod || formErrors.currency)
+ hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractStartDate || formErrors.contractEndDate || formErrors.currency)
},
schedule: {
isValid: formValues.submissionStartDate &&
@@ -289,7 +373,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
hasErrors: false
},
details: {
- isValid: true, // 세부내역은 선택사항
+ isValid: prItems.length > 0,
hasErrors: false
},
manager: {
@@ -369,6 +453,12 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
// PR 아이템 제거
const removePRItem = (id: string) => {
+ // 최소 하나의 아이템은 유지해야 함
+ if (prItems.length <= 1) {
+ toast.error("최소 하나의 품목이 필요합니다.")
+ return
+ }
+
setPrItems(prev => {
const filteredItems = prev.filter(item => item.id !== id)
// 만약 대표 아이템을 삭제했다면, 첫 번째 아이템을 대표로 설정
@@ -443,7 +533,9 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
toast.error("제출 시작일시와 마감일시를 입력해주세요")
}
} else if (activeTab === "conditions") {
- toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일, 선적지, 도착지)")
+ toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일, 선적지, 하역지)")
+ } else if (activeTab === "details") {
+ toast.error("품목정보, 수량/단위 또는 중량/중량단위를 입력해주세요")
}
return
}
@@ -524,7 +616,8 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
contractType: "general",
biddingType: "equipment",
awardCount: "single",
- contractPeriod: "",
+ contractStartDate: "",
+ contractEndDate: "",
submissionStartDate: "",
submissionEndDate: "",
hasSpecificationMeeting: false,
@@ -556,7 +649,23 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
isRequired: false,
meetingFiles: [],
})
- setPrItems([])
+ setPrItems([
+ {
+ id: `pr-default`,
+ prNumber: "",
+ itemCode: "",
+ itemInfo: "",
+ quantity: "",
+ quantityUnit: "EA",
+ totalWeight: "",
+ weightUnit: "KG",
+ materialDescription: "",
+ hasSpecDocument: false,
+ requestedDeliveryDate: "",
+ specFiles: [],
+ isRepresentative: true, // 첫 번째 아이템은 대표 아이템
+ }
+ ])
setSelectedItemForFile(null)
setBiddingConditions({
paymentTerms: "",
@@ -705,6 +814,9 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
}`}
>
세부내역
+ {!tabValidation.details.isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
</button>
<button
type="button"
@@ -927,18 +1039,34 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
)}
/>
- {/* 계약기간 */}
+ {/* 계약 시작일 */}
<FormField
control={form.control}
- name="contractPeriod"
+ name="contractStartDate"
render={({ field }) => (
<FormItem>
- <FormLabel>
- 계약기간 <span className="text-red-500">*</span>
- </FormLabel>
+ <FormLabel>계약 시작일 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약 종료일 */}
+ <FormField
+ control={form.control}
+ name="contractEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약 종료일 <span className="text-red-500">*</span></FormLabel>
<FormControl>
<Input
- placeholder="예: 계약일로부터 60일"
+ type="date"
{...field}
/>
</FormControl>
@@ -1403,26 +1531,58 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
<div className="space-y-2">
<label className="text-sm font-medium">선적지 <span className="text-red-500">*</span></label>
- <Input
- placeholder="예: 부산항, 인천항"
+ <Select
value={biddingConditions.shippingPort}
- onChange={(e) => setBiddingConditions(prev => ({
+ onValueChange={(value) => setBiddingConditions(prev => ({
...prev,
- shippingPort: e.target.value
+ shippingPort: value
}))}
- />
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선적지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</div>
<div className="space-y-2">
- <label className="text-sm font-medium">도착지 <span className="text-red-500">*</span></label>
- <Input
- placeholder="예: 현장 직납, 창고 납품"
+ <label className="text-sm font-medium">하역지 <span className="text-red-500">*</span></label>
+ <Select
value={biddingConditions.destinationPort}
- onChange={(e) => setBiddingConditions(prev => ({
+ onValueChange={(value) => setBiddingConditions(prev => ({
...prev,
- destinationPort: e.target.value
+ destinationPort: value
}))}
- />
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="하역지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</div>
</div>
@@ -1463,7 +1623,10 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
<div>
<CardTitle>세부내역 관리</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
- PR 아이템 또는 수기 아이템을 추가하여 입찰 세부내역을 관리하세요
+ 최소 하나의 품목을 입력해야 합니다
+ </p>
+ <p className="text-xs text-amber-600 mt-1">
+ 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
</p>
</div>
<Button
@@ -1487,7 +1650,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
<TableHead className="w-[60px]">대표</TableHead>
<TableHead className="w-[120px]">PR 번호</TableHead>
<TableHead className="w-[120px]">품목코드</TableHead>
- <TableHead>품목정보</TableHead>
+ <TableHead>품목정보 *</TableHead>
<TableHead className="w-[80px]">수량</TableHead>
<TableHead className="w-[80px]">단위</TableHead>
<TableHead className="w-[80px]">중량</TableHead>
@@ -1526,7 +1689,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
</TableCell>
<TableCell>
<Input
- placeholder="품목정보"
+ placeholder="품목정보 *"
value={item.itemInfo}
onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })}
className="h-8"
@@ -1535,6 +1698,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
<TableCell>
<Input
type="number"
+ min="0"
placeholder="수량"
value={item.quantity}
onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
@@ -1562,6 +1726,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
<TableCell>
<Input
type="number"
+ min="0"
placeholder="중량"
value={item.totalWeight}
onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
@@ -1590,6 +1755,7 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
value={item.requestedDeliveryDate}
onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
className="h-8"
+ placeholder="납품요청일"
/>
</TableCell>
<TableCell>
@@ -1612,7 +1778,9 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
variant="outline"
size="sm"
onClick={() => removePRItem(item.id)}
+ disabled={prItems.length <= 1}
className="h-8 w-8 p-0"
+ title={prItems.length <= 1 ? "최소 하나의 품목이 필요합니다" : "품목 삭제"}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -1978,7 +2146,14 @@ export function CreateBiddingDialog({ paymentTermsOptions = [], incotermsOptions
)}
</span>
)}
- {activeTab === "details" && "세부내역 아이템을 관리하세요 (선택사항)"}
+ {activeTab === "details" && (
+ <span>
+ 최소 하나의 품목을 입력하세요
+ {!tabValidation.details.isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </span>
+ )}
{activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"}
</div>
diff --git a/lib/bidding/list/edit-bidding-sheet.tsx b/lib/bidding/list/edit-bidding-sheet.tsx
index c76ec2a2..dc24d0cf 100644
--- a/lib/bidding/list/edit-bidding-sheet.tsx
+++ b/lib/bidding/list/edit-bidding-sheet.tsx
@@ -82,7 +82,8 @@ export function EditBiddingSheet({
contractType: "general",
biddingType: "equipment",
awardCount: "single",
- contractPeriod: "",
+ contractStartDate: "",
+ contractEndDate: "",
preQuoteDate: "",
biddingRegistrationDate: "",
@@ -126,7 +127,8 @@ export function EditBiddingSheet({
contractType: bidding.contractType || "general",
biddingType: bidding.biddingType || "equipment",
awardCount: bidding.awardCount || "single",
- contractPeriod: bidding.contractPeriod || "",
+ contractStartDate: formatDate(bidding.contractStartDate, "kr"),
+ contractEndDate: formatDate(bidding.contractEndDate, "kr"),
preQuoteDate: formatDate(bidding.preQuoteDate, "kr"),
biddingRegistrationDate: formatDate(bidding.biddingRegistrationDate, "kr"),
@@ -356,6 +358,37 @@ export function EditBiddingSheet({
/>
</div>
+ {/* 계약 기간 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="contractStartDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약 시작일</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contractEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약 종료일</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
<FormField
control={form.control}
name="status"
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index cad77a6b..0f284297 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -57,13 +57,7 @@ interface PrItemQuotation {
technicalSpecification?: string
}
-interface PreQuoteDocumentUpload {
- fileName: string
- originalFileName: string
- fileSize: number
- mimeType: string
- filePath: string
-}
+
// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
@@ -448,7 +442,8 @@ export async function getBiddingCompaniesForPartners(biddingId: number, companyI
contractType: biddings.contractType,
biddingType: biddings.biddingType,
awardCount: biddings.awardCount,
- contractPeriod: biddings.contractPeriod,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
preQuoteDate: biddings.preQuoteDate,
biddingRegistrationDate: biddings.biddingRegistrationDate,
submissionStartDate: biddings.submissionStartDate,
@@ -743,7 +738,7 @@ export async function respondToPreQuoteInvitation(
}
}
-// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteSelected, isPreQuoteParticipated 사용)
+// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용)
export async function setPreQuoteParticipation(
biddingCompanyId: number,
isParticipating: boolean
@@ -752,7 +747,6 @@ export async function setPreQuoteParticipation(
await db.update(biddingCompanies)
.set({
isPreQuoteParticipated: isParticipating,
- isPreQuoteSelected: isParticipating,
respondedAt: new Date(),
updatedAt: new Date()
})
@@ -1433,7 +1427,7 @@ export async function sendBiddingBasicContracts(
message: `${results.length}개의 기본계약이 생성되었습니다.`,
results,
savedContracts,
- totalContracts: savedContracts.length
+ totalContracts: savedContracts.length,
}
})
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
index 5c6f41ce..3266a568 100644
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
+++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
@@ -338,7 +338,7 @@ export function getBiddingPreQuoteVendorColumns({
},
{
accessorKey: 'proposedDestinationPort',
- header: '제안도착지',
+ header: '제안하역지',
cell: ({ row }) => (
<div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}>
{row.original.proposedDestinationPort || '-'}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 55146c4b..89e4f80f 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -13,9 +13,6 @@ import {
biddingConditions,
users,
basicContractTemplates,
- paymentTerms,
- incoterms,
- vendors,
vendorsWithTypesView,
biddingCompanies
} from '@/db/schema'
@@ -30,10 +27,11 @@ import {
ilike,
gte,
lte,
- SQL, like
+ SQL,
+ like,
+ notInArray
} from 'drizzle-orm'
import { revalidatePath } from 'next/cache'
-import { BiddingListItem } from '@/db/schema'
import { filterColumns } from '@/lib/filter-columns'
import { CreateBiddingSchema, GetBiddingsSchema, UpdateBiddingSchema } from './validation'
import { saveFile } from '../file-stroage'
@@ -167,17 +165,17 @@ export async function getBiddings(input: GetBiddingsSchema) {
}
if (input.submissionDateFrom) {
- basicConditions.push(gte(biddingListView.submissionStartDate, input.submissionDateFrom))
+ basicConditions.push(gte(biddingListView.submissionStartDate, new Date(input.submissionDateFrom)))
}
if (input.submissionDateTo) {
- basicConditions.push(lte(biddingListView.submissionEndDate, input.submissionDateTo))
+ basicConditions.push(lte(biddingListView.submissionEndDate, new Date(input.submissionDateTo)))
}
if (input.createdAtFrom) {
- basicConditions.push(gte(biddingListView.createdAt, input.createdAtFrom))
+ basicConditions.push(gte(biddingListView.createdAt, new Date(input.createdAtFrom)))
}
if (input.createdAtTo) {
- basicConditions.push(lte(biddingListView.createdAt, input.createdAtTo))
+ basicConditions.push(lte(biddingListView.createdAt, new Date(input.createdAtTo)))
}
// 가격 범위 필터
@@ -367,118 +365,129 @@ export async function getBiddingMonthlyStats(year: number = new Date().getFullYe
}
export interface CreateBiddingInput extends CreateBiddingSchema {
- // 사양설명회 정보 (선택사항)
- specificationMeeting?: {
- meetingDate: string
- meetingTime: string
- location: string
- address: string
- contactPerson: string
- contactPhone: string
- contactEmail: string
- agenda: string
- materials: string
- notes: string
- isRequired: boolean
- meetingFiles: File[]
- } | null
-
- // PR 아이템들 (선택사항)
- prItems?: Array<{
- id: string
- prNumber: string
- itemCode: string
- itemInfo: string
- quantity: string
- quantityUnit: string
- totalWeight: string
- weightUnit: string
- materialDescription: string
- hasSpecDocument: boolean
- requestedDeliveryDate: string
- specFiles: File[]
- isRepresentative: boolean
- }>
-
- // 입찰 조건 (선택사항)
- biddingConditions?: {
- paymentTerms: string
- taxConditions: string
- incoterms: string
- contractDeliveryDate: string
- shippingPort: string
- destinationPort: string
- isPriceAdjustmentApplicable: boolean
- sparePartOptions: string
- }
+ // 사양설명회 정보 (선택사항)
+ specificationMeeting?: {
+ meetingDate: string
+ meetingTime: string
+ location: string
+ address: string
+ contactPerson: string
+ contactPhone: string
+ contactEmail: string
+ agenda: string
+ materials: string
+ notes: string
+ isRequired: boolean
+ meetingFiles: File[]
+ } | null
+
+ // PR 아이템들 (선택사항)
+ prItems?: Array<{
+ id: string
+ prNumber: string
+ itemCode: string
+ itemInfo: string
+ quantity: string
+ quantityUnit: string
+ totalWeight: string
+ weightUnit: string
+ materialDescription: string
+ hasSpecDocument: boolean
+ requestedDeliveryDate: string
+ specFiles: File[]
+ isRepresentative: boolean
+ }>
+
+ // 입찰 조건 (선택사항)
+ biddingConditions?: {
+ paymentTerms: string
+ taxConditions: string
+ incoterms: string
+ contractDeliveryDate: string
+ shippingPort: string
+ destinationPort: string
+ isPriceAdjustmentApplicable: boolean
+ sparePartOptions: string
}
+ // 계약 기간 정보
+ contractStartDate?: string
+ contractEndDate?: string
+}
+
export interface UpdateBiddingInput extends UpdateBiddingSchema {
id: number
}
// 자동 입찰번호 생성
-async function generateBiddingNumber(biddingType: string, tx?: any, maxRetries: number = 5): Promise<string> {
- const year = new Date().getFullYear()
- const typePrefix = {
- 'equipment': 'EQ',
- 'construction': 'CT',
- 'service': 'SV',
- 'lease': 'LS',
- 'steel_stock': 'SS',
- 'piping': 'PP',
- 'transport': 'TP',
- 'waste': 'WS',
- 'sale': 'SL'
- }[biddingType] || 'GN'
-
- const dbInstance = tx || db
- const prefix = `${year}${typePrefix}`
+async function generateBiddingNumber(
+ userId?: string,
+ tx?: any,
+ maxRetries: number = 5
+): Promise<string> {
+ // user 테이블의 user.userCode가 있으면 발주담당자 코드로 사용
+ // userId가 주어졌을 때 user.userCode를 조회, 없으면 '000' 사용
+ let purchaseManagerCode = '000';
+ if (userId) {
+ const user = await db
+ .select({ userCode: users.userCode })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1);
+ if (user[0]?.userCode && user[0].userCode.length >= 3) {
+ purchaseManagerCode = user[0].userCode.substring(0, 3).toUpperCase();
+ }
+ }
+ const managerCode = (purchaseManagerCode && purchaseManagerCode.length >= 3)
+ ? purchaseManagerCode.substring(0, 3).toUpperCase()
+ : '000';
+
+ const dbInstance = tx || db;
+ const prefix = `B${managerCode}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
- // 현재 최대 시퀀스 번호 조회
+ // 현재 최대 일련번호 조회
const result = await dbInstance
.select({
maxNumber: sql<string>`MAX(${biddings.biddingNumber})`
})
.from(biddings)
- .where(like(biddings.biddingNumber, `${prefix}%`))
+ .where(like(biddings.biddingNumber, `${prefix}%`));
- let sequence = 1
+ let sequence = 1;
if (result[0]?.maxNumber) {
- const lastSequence = parseInt(result[0].maxNumber.slice(-4))
+ const lastSequence = parseInt(result[0].maxNumber.slice(-5));
if (!isNaN(lastSequence)) {
- sequence = lastSequence + 1
+ sequence = lastSequence + 1;
}
}
- const biddingNumber = `${prefix}${sequence.toString().padStart(4, '0')}`
+ const biddingNumber = `${prefix}${sequence.toString().padStart(5, '0')}`;
// 중복 확인
const existing = await dbInstance
.select({ id: biddings.id })
.from(biddings)
.where(eq(biddings.biddingNumber, biddingNumber))
- .limit(1)
+ .limit(1);
if (existing.length === 0) {
- return biddingNumber
+ return biddingNumber;
}
- // 중복이 발견되면 잠시 대기 후 재시도
- await new Promise(resolve => setTimeout(resolve, 10 + Math.random() * 20))
+ // 중복이 발견되면 잠시 대기 후 재시도 (동시성 문제 방지)
+ await new Promise(resolve => setTimeout(resolve, 10 + Math.random() * 20));
}
- throw new Error(`Failed to generate unique bidding number after ${maxRetries} attempts`)
+ throw new Error(`Failed to generate unique bidding number after ${maxRetries} attempts`);
}
-
// 입찰 생성
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)
+ const biddingNumber = await generateBiddingNumber(userId)
// 프로젝트 정보 조회
let projectName = input.projectName
@@ -541,7 +550,8 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
contractType: input.contractType,
biddingType: input.biddingType,
awardCount: input.awardCount,
- contractPeriod: input.contractPeriod,
+ contractStartDate: input.contractStartDate ? parseDate(input.contractStartDate) : null,
+ contractEndDate: input.contractEndDate ? parseDate(input.contractEndDate) : null,
// 자동 등록일 설정
biddingRegistrationDate: new Date(),
@@ -640,7 +650,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
paymentTerms: input.biddingConditions.paymentTerms,
taxConditions: input.biddingConditions.taxConditions,
incoterms: input.biddingConditions.incoterms,
- contractDeliveryDate: input.biddingConditions.contractDeliveryDate ? new Date(input.biddingConditions.contractDeliveryDate) : null,
+ contractDeliveryDate: input.biddingConditions.contractDeliveryDate || null,
shippingPort: input.biddingConditions.shippingPort,
destinationPort: input.biddingConditions.destinationPort,
isPriceAdjustmentApplicable: input.biddingConditions.isPriceAdjustmentApplicable,
@@ -703,7 +713,6 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
isPublic: false,
isRequired: false,
uploadedBy: userName,
- displayOrder: fileIndex + 1,
})
} else {
console.error(`Failed to save spec file: ${file.name}`, saveResult.error)
@@ -796,10 +805,9 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) {
if (input.contractType !== undefined) updateData.contractType = input.contractType
if (input.biddingType !== undefined) updateData.biddingType = input.biddingType
if (input.awardCount !== undefined) updateData.awardCount = input.awardCount
- if (input.contractPeriod !== undefined) updateData.contractPeriod = input.contractPeriod
+ if (input.contractStartDate !== undefined) updateData.contractStartDate = parseDate(input.contractStartDate)
+ if (input.contractEndDate !== undefined) updateData.contractEndDate = parseDate(input.contractEndDate)
- if (input.preQuoteDate !== undefined) updateData.preQuoteDate = parseDate(input.preQuoteDate)
- if (input.biddingRegistrationDate !== undefined) updateData.biddingRegistrationDate = parseDate(input.biddingRegistrationDate)
if (input.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(input.submissionStartDate)
if (input.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(input.submissionEndDate)
if (input.evaluationDate !== undefined) updateData.evaluationDate = parseDate(input.evaluationDate)
@@ -1039,15 +1047,15 @@ export async function getSpecificationMeetingDetailsAction(
biddingId: meetingData.biddingId,
meetingDate: meetingData.meetingDate?.toISOString() || '',
meetingTime: meetingData.meetingTime,
- location: meetingData.location,
+ location: meetingData.location || '',
address: meetingData.address,
- contactPerson: meetingData.contactPerson,
+ contactPerson: meetingData.contactPerson || '',
contactPhone: meetingData.contactPhone,
contactEmail: meetingData.contactEmail,
agenda: meetingData.agenda,
materials: meetingData.materials,
notes: meetingData.notes,
- isRequired: meetingData.isRequired,
+ isRequired: meetingData.isRequired || false,
createdAt: meetingData.createdAt?.toISOString() || '',
updatedAt: meetingData.updatedAt?.toISOString() || '',
documents: documents.map(doc => ({
@@ -1183,7 +1191,7 @@ export async function getPRDetailsAction(
createdAt: doc.createdAt?.toISOString() || '',
updatedAt: doc.updatedAt?.toISOString() || '',
})),
- items: itemsWithDocs
+ items: itemsWithDocs as any
}
return {
@@ -1200,8 +1208,6 @@ export async function getPRDetailsAction(
}
}
-
-
/**
* 입찰 기본 정보 조회 서버 액션 (선택사항)
*/
@@ -1241,7 +1247,7 @@ export async function getBiddingBasicInfoAction(
return {
success: true,
- data: bidding
+ data: bidding as any
}
} catch (error) {
@@ -1306,7 +1312,7 @@ export async function updateBiddingConditions(
paymentTerms: updates.paymentTerms,
taxConditions: updates.taxConditions,
incoterms: updates.incoterms,
- contractDeliveryDate: updates.contractDeliveryDate ? new Date(updates.contractDeliveryDate) : null,
+ contractDeliveryDate: updates.contractDeliveryDate || null,
shippingPort: updates.shippingPort,
destinationPort: updates.destinationPort,
isPriceAdjustmentApplicable: updates.isPriceAdjustmentApplicable,
@@ -1324,7 +1330,7 @@ export async function updateBiddingConditions(
// 새로 생성
await tx.insert(biddingConditions).values({
biddingId,
- ...updateData,
+ ...updateData as any,
})
}
@@ -1346,59 +1352,6 @@ export async function updateBiddingConditions(
}
// 활성 템플릿 조회 서버 액션
-// 입찰 조건 옵션 관련 서버 액션들
-export async function getActivePaymentTerms() {
- try {
- const result = await db
- .select({
- code: paymentTerms.code,
- description: paymentTerms.description,
- isActive: paymentTerms.isActive,
- createdAt: paymentTerms.createdAt,
- })
- .from(paymentTerms)
- .where(eq(paymentTerms.isActive, true))
- .orderBy(paymentTerms.createdAt)
-
- return {
- success: true,
- data: result
- }
- } catch (error) {
- console.error('Error fetching active payment terms:', error)
- return {
- success: false,
- error: '지급조건 조회 중 오류가 발생했습니다.'
- }
- }
-}
-
-export async function getActiveIncoterms() {
- try {
- const result = await db
- .select({
- code: incoterms.code,
- description: incoterms.description,
- isActive: incoterms.isActive,
- createdAt: incoterms.createdAt,
- })
- .from(incoterms)
- .where(eq(incoterms.isActive, true))
- .orderBy(incoterms.createdAt)
-
- return {
- success: true,
- data: result
- }
- } catch (error) {
- console.error('Error fetching active incoterms:', error)
- return {
- success: false,
- error: '운송조건 조회 중 오류가 발생했습니다.'
- }
- }
-}
-
export async function getActiveContractTemplates() {
try {
// 활성 상태의 템플릿들 조회
@@ -1461,7 +1414,7 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId
and(
whereCondition,
// 이미 참여중인 벤더 제외
- excludedIds.length > 0 ? sql`${vendorsWithTypesView.id} NOT IN (${excludedIds})` : undefined,
+ excludedIds.length > 0 ? notInArray(vendorsWithTypesView.id, excludedIds) : undefined,
// ACTIVE 상태인 벤더만 검색
// eq(vendorsWithTypesView.status, "ACTIVE"),
)
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index ab330596..2011cd27 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -1,4 +1,4 @@
-import { BiddingListView, biddings, type Bidding } from "@/db/schema"
+import { BiddingListView, biddings } from "@/db/schema"
import {
createSearchParamsCache,
parseAsArrayOf,
@@ -74,7 +74,8 @@ export const createBiddingSchema = z.object({
awardCount: z.enum(biddings.awardCount.enumValues, {
required_error: "낙찰수를 선택해주세요"
}),
- contractPeriod: z.string().min(1, "계약기간은 필수입니다"),
+ contractStartDate: z.string().optional(),
+ contractEndDate: z.string().optional(),
// ✅ 일정 (제출기간 필수)
submissionStartDate: z.string().min(1, "제출시작일시는 필수입니다"),
@@ -110,7 +111,7 @@ export const createBiddingSchema = z.object({
incoterms: z.string().min(1, "운송조건은 필수입니다"),
contractDeliveryDate: z.string().min(1, "계약납품일은 필수입니다"),
shippingPort: z.string().min(1, "선적지는 필수입니다"),
- destinationPort: z.string().min(1, "도착지는 필수입니다"),
+ destinationPort: z.string().min(1, "하역지는 필수입니다"),
isPriceAdjustmentApplicable: z.boolean().default(false),
sparePartOptions: z.string().optional(),
}).optional(),
@@ -141,7 +142,8 @@ export const createBiddingSchema = z.object({
contractType: z.enum(biddings.contractType.enumValues).optional(),
biddingType: z.enum(biddings.biddingType.enumValues).optional(),
awardCount: z.enum(biddings.awardCount.enumValues).optional(),
- contractPeriod: z.string().optional(),
+ contractStartDate: z.string().optional(),
+ contractEndDate: z.string().optional(),
submissionStartDate: z.string().optional(),
submissionEndDate: z.string().optional(),
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 13804251..483bce5c 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -4,8 +4,7 @@ import * as React from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { Button } from '@/components/ui/button'
-import { Textarea } from '@/components/ui/textarea'
+
import { Badge } from '@/components/ui/badge'
import {
Table,
@@ -17,7 +16,7 @@ import {
} from '@/components/ui/table'
import {
Package,
- FileText,
+
Download,
Calculator
} from 'lucide-react'
diff --git a/lib/bidding/vendor/components/simple-file-upload.tsx b/lib/bidding/vendor/components/simple-file-upload.tsx
index 58b60bdf..1344a491 100644
--- a/lib/bidding/vendor/components/simple-file-upload.tsx
+++ b/lib/bidding/vendor/components/simple-file-upload.tsx
@@ -5,7 +5,6 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
@@ -15,7 +14,6 @@ import {
TableRow,
} from '@/components/ui/table'
import {
- Upload,
FileText,
Download,
Trash2
@@ -171,6 +169,7 @@ export function SimpleFileUpload({
throw new Error('파일 정보가 없습니다.')
}
} catch (error) {
+ console.error('파일 다운로드 실패:', error)
toast({
title: '다운로드 실패',
description: '파일 다운로드에 실패했습니다.',
diff --git a/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
index 951923ca..f5206c71 100644
--- a/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
+++ b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
@@ -121,6 +121,7 @@ export function PartnersBiddingAttachmentsDialog({
throw new Error('파일 정보가 없습니다.')
}
} catch (error) {
+ console.error('파일 다운로드 실패:', error)
toast({
title: '다운로드 실패',
description: '파일 다운로드에 실패했습니다.',
diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
index 6276e433..d0ef97f1 100644
--- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
+++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
@@ -13,28 +13,24 @@ import {
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Input } from '@/components/ui/input'
-import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Calendar,
Users,
- MapPin,
Clock,
- FileText,
CheckCircle,
XCircle,
Download,
- User,
- Phone
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { updatePartnerAttendance, getSpecificationMeetingForPartners } from '../detail/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
+import { useRouter } from 'next/navigation'
-interface PartnersBiddingAttendanceDialogProps {
+interface PartnersSpecificationMeetingDialogProps {
biddingDetail: {
id: number
biddingNumber: string
@@ -48,22 +44,20 @@ interface PartnersBiddingAttendanceDialogProps {
isAttending: boolean | null
open: boolean
onOpenChange: (open: boolean) => void
- onSuccess: () => void
}
-export function PartnersBiddingAttendanceDialog({
+export function PartnersSpecificationMeetingDialog({
biddingDetail,
biddingCompanyId,
isAttending,
open,
onOpenChange,
- onSuccess,
-}: PartnersBiddingAttendanceDialogProps) {
+}: PartnersSpecificationMeetingDialogProps) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
const [isLoading, setIsLoading] = React.useState(false)
const [meetingData, setMeetingData] = React.useState<any>(null)
-
+ const router = useRouter()
// 폼 상태
const [attendance, setAttendance] = React.useState<string>('')
const [attendeeCount, setAttendeeCount] = React.useState<string>('')
@@ -93,6 +87,7 @@ export function PartnersBiddingAttendanceDialog({
})
}
} catch (error) {
+ console.error('사양설명회 정보를 불러오는데 실패했습니다.', error)
toast({
title: '오류',
description: '사양설명회 정보를 불러오는데 실패했습니다.',
@@ -178,7 +173,7 @@ export function PartnersBiddingAttendanceDialog({
})
}
- onSuccess()
+ router.refresh()
onOpenChange(false)
} else {
toast({
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 89ca426b..d134bc3b 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -5,30 +5,24 @@ import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
-import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { Textarea } from '@/components/ui/textarea'
-import { Checkbox } from '@/components/ui/checkbox'
import {
ArrowLeft,
- Calendar,
- Building2,
- Package,
User,
- DollarSign,
- FileText,
Users,
Send,
CheckCircle,
XCircle,
- Save
+ Save,
+ FileText,
+ Building2,
+ Package
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import {
getBiddingDetailsForPartners,
submitPartnerResponse,
- updatePartnerAttendance,
updatePartnerBiddingParticipation,
saveBiddingDraft
} from '../detail/service'
@@ -61,7 +55,8 @@ interface BiddingDetail {
contractType: string
biddingType: string
awardCount: string | null
- contractPeriod: string | null
+ contractStartDate: Date | null
+ contractEndDate: Date | null
preQuoteDate: Date | null
biddingRegistrationDate: Date | null
submissionStartDate: Date | null
@@ -180,10 +175,10 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
setTotalQuotationAmount(total)
// 응찰 확정 시에만 사전견적 금액을 finalQuoteAmount로 설정
- if (total > 0 && result.isBiddingParticipated === true) {
+ if (totalQuotationAmount > 0 && result.isBiddingParticipated === true) {
setResponseData(prev => ({
...prev,
- finalQuoteAmount: total.toString()
+ finalQuoteAmount: totalQuotationAmount.toString()
}))
}
} catch (error) {
@@ -455,13 +450,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
})
}
- const formatCurrency = (amount: number) => {
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: biddingDetail?.currency || 'KRW',
- }).format(amount)
- }
-
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
@@ -497,9 +485,11 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<div>
<h1 className="text-2xl font-semibold">{biddingDetail.title}</h1>
<div className="flex items-center gap-2 mt-1">
- <Badge variant="outline" className="font-mono">
+ <Badge variant="outline" className="font-mono text-xs">
{biddingDetail.biddingNumber}
- {biddingDetail.revision && biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`}
+ </Badge>
+ <Badge variant="outline" className="font-mono">
+ Rev. {biddingDetail.revision ?? 0}
</Badge>
<Badge variant={
biddingDetail.status === 'bidding_disposal' ? 'destructive' :
@@ -670,28 +660,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
- {/* 품목별 견적 섹션 */}
- {/* <div className="space-y-2">
- <Label htmlFor="finalQuoteAmount">총 견적금액 *</Label>
- <Input
- id="finalQuoteAmount"
- type="number"
- value={responseData.finalQuoteAmount}
- onChange={(e) => setResponseData({...responseData, finalQuoteAmount: e.target.value})}
- placeholder="총 견적금액을 입력하세요"
- />
- </div> */}
-
- {/* <div className="space-y-2">
- <Label htmlFor="proposedContractDeliveryDate">제안 납품일</Label>
- <Input
- id="proposedContractDeliveryDate"
- type="date"
- value={responseData.proposedContractDeliveryDate}
- onChange={(e) => setResponseData({...responseData, proposedContractDeliveryDate: e.target.value})}
- />
- </div> */}
-
{/* 품목별 상세 견적 테이블 */}
{prItems.length > 0 ? (
<PrItemsPricingTable
@@ -719,18 +687,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
readOnly={false}
/>
)}
-
- {/* 기타 사항 */}
- {/* <div className="space-y-2">
- <Label htmlFor="additionalProposals">기타 사항</Label>
- <Textarea
- id="additionalProposals"
- value={responseData.additionalProposals}
- onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})}
- placeholder="기타 특이사항이나 제안사항을 입력하세요"
- rows={4}
- />
- </div> */}
{/* 응찰 제출 버튼 - 참여 확정 상태일 때만 표시 */}
<div className="flex justify-end pt-4 gap-2">
<Button
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 534e8838..7fb62122 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -13,7 +13,6 @@ import {
import {
CheckCircle,
XCircle,
- Users,
FileText,
MoreHorizontal,
Calendar,
@@ -67,9 +66,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
return (
<div className="font-mono text-sm">
<div>{biddingNumber}</div>
- {revision > 0 && (
- <div className="text-muted-foreground">Rev.{revision}</div>
- )}
+ <div className="text-muted-foreground">Rev. {revision ?? 0}</div>
</div>
)
},
@@ -160,15 +157,6 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}
}
- const handleAttendance = () => {
- if (setRowAction) {
- setRowAction({
- type: 'attendance',
- row: { original: row.original }
- })
- }
- }
-
const handlePreQuote = () => {
if (setRowAction) {
setRowAction({
@@ -262,24 +250,16 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}),
// 입찰 참여의사
- columnHelper.accessor('invitationStatus', {
+ columnHelper.accessor('isBiddingParticipated', {
header: '입찰 참여의사',
cell: ({ row }) => {
- const status = row.original.invitationStatus
- const statusLabels = {
- sent: '초대됨',
- submitted: '참여',
- declined: '불참',
- pending: '대기중'
+ const participated = row.original.isBiddingParticipated
+ if (participated === null) {
+ return <Badge variant="outline">미결정</Badge>
}
return (
- <Badge variant={
- status === 'submitted' ? 'default' :
- status === 'declined' ? 'destructive' :
- status === 'sent' ? 'secondary' :
- 'outline'
- }>
- {statusLabels[status as keyof typeof statusLabels] || status}
+ <Badge variant={participated ? 'default' : 'destructive'}>
+ {participated ? '참여' : '불참'}
</Badge>
)
},
@@ -340,13 +320,22 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}),
// 계약기간
- columnHelper.accessor('contractPeriod', {
+ columnHelper.accessor('contractStartDate', {
header: '계약기간',
- cell: ({ row }) => (
- <div className="max-w-24 truncate" title={row.original.contractPeriod || ''}>
- {row.original.contractPeriod || '-'}
- </div>
- ),
+ cell: ({ row }) => {
+ const startDate = row.original.contractStartDate
+ const endDate = row.original.contractEndDate
+
+ if (!startDate || !endDate) {
+ return <div className="max-w-24 truncate">-</div>
+ }
+
+ return (
+ <div className="max-w-24 truncate" title={`${formatDate(startDate, 'KR')} ~ ${formatDate(endDate, 'KR')}`}>
+ {formatDate(startDate, 'KR')} ~ {formatDate(endDate, 'KR')}
+ </div>
+ )
+ },
}),
// 참여회신 마감일
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx
index eb38ce71..fc3cd1f2 100644
--- a/lib/bidding/vendor/partners-bidding-list.tsx
+++ b/lib/bidding/vendor/partners-bidding-list.tsx
@@ -5,7 +5,6 @@ import { useRouter } from 'next/navigation'
import type {
DataTableAdvancedFilterField,
DataTableFilterField,
- DataTableRowAction,
} from '@/types/table'
import { useDataTable } from '@/hooks/use-data-table'
@@ -13,94 +12,36 @@ import { useToast } from '@/hooks/use-toast'
import { DataTable } from '@/components/data-table/data-table'
import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'
import { getPartnersBiddingListColumns } from './partners-bidding-list-columns'
-import { getBiddingListForPartners, PartnersBiddingListItem } from '../detail/service'
+import { PartnersBiddingListItem } from '../detail/service'
import { PartnersBiddingToolbarActions } from './partners-bidding-toolbar-actions'
-import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog'
-import { PartnersBiddingParticipationDialog } from './partners-bidding-participation-dialog'
+import { PartnersSpecificationMeetingDialog } from './partners-bidding-attendance-dialog'
import { PartnersBiddingAttachmentsDialog } from './partners-bidding-attachments-dialog'
-import { setPreQuoteParticipation, getBiddingCompaniesForPartners } from '../pre-quote/service'
interface PartnersBiddingListProps {
companyId: number
+ promises: Promise<[PartnersBiddingListItem[]]>
}
-export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
- const [data, setData] = React.useState<PartnersBiddingListItem[]>([])
- const [pageCount, setPageCount] = React.useState<number>(1)
- const [isLoading, setIsLoading] = React.useState(true)
+export function PartnersBiddingList({ promises }: PartnersBiddingListProps) {
+ const [biddingData] = React.use(promises)
const [rowAction, setRowAction] = React.useState<{ type: string; row: { original: PartnersBiddingListItem } } | null>(null)
- const [isParticipationDialogOpen, setIsParticipationDialogOpen] = React.useState(false)
- const [selectedBiddingForParticipation, setSelectedBiddingForParticipation] = React.useState<PartnersBiddingListItem | null>(null)
- const [selectedBiddingForPreQuoteParticipation, setSelectedBiddingForPreQuoteParticipation] = React.useState<any | null>(null)
+ // const [selectedBiddingForSpecificationMeetingParticipation] = React.useState<PartnersBiddingListItem | null>(null)
const [isAttachmentsDialogOpen, setIsAttachmentsDialogOpen] = React.useState(false)
const [selectedBiddingForAttachments, setSelectedBiddingForAttachments] = React.useState<PartnersBiddingListItem | null>(null)
-
const router = useRouter()
const { toast } = useToast()
- // 데이터 새로고침 함수
- const refreshData = React.useCallback(async () => {
- try {
- setIsLoading(true)
- const result = await getBiddingListForPartners(companyId)
- setData(result)
- } catch (error) {
- console.error('Failed to refresh bidding list:', error)
- } finally {
- setIsLoading(false)
- }
- }, [companyId])
-
- // 입찰 참여의사 결정 핸들러
- const handlePreQuoteParticipationDecision = React.useCallback(async (participate: boolean) => {
- if (!selectedBiddingForPreQuoteParticipation?.biddingCompanyId) {
- throw new Error('업체 정보를 찾을 수 없습니다.')
- }
-
- const result = await setPreQuoteParticipation(
- selectedBiddingForPreQuoteParticipation.biddingCompanyId,
- participate
- )
-
- if (result.success) {
- await refreshData() // 데이터 새로고침
- } else {
- throw new Error(result.error)
- }
- }, [selectedBiddingForPreQuoteParticipation?.biddingCompanyId, refreshData])
-
- // 데이터 로드
- React.useEffect(() => {
- const loadData = async () => {
- try {
- setIsLoading(true)
- const result = await getBiddingListForPartners(companyId)
- setData(result)
- setPageCount(1) // 클라이언트 사이드 페이징이므로 1로 설정
- } catch (error) {
- console.error('Failed to load bidding list:', error)
- setData([])
- } finally {
- setIsLoading(false)
- }
- }
-
- loadData()
- }, [companyId])
-
- // rowAction 변경 감지하여 해당 페이지로 이동 또는 다이얼로그 열기
- React.useEffect(() => {
+ React.useEffect(() => {
if (rowAction) {
+ const bidding = rowAction.row.original
switch (rowAction.type) {
case 'view':
- // 본입찰 초대 여부 확인
- const bidding = rowAction.row.original
// 사전견적 요청 상태에서는 상세보기 제한
if (bidding.status === 'request_for_quotation') {
toast({
title: '접근 제한',
- description: '사전견적 요청 상태에서는 상세보기를 이용할 수 없습니다.',
+ description: '사전견적 요청 상태에서는 본입찰을 이용할 수 없습니다.',
variant: 'destructive',
})
return
@@ -116,16 +57,21 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
return
}
// 상세 페이지로 이동 (biddingId 사용)
- router.push(`/partners/bid/${rowAction.row.original.biddingId}`)
+ router.push(`/partners/bid/${bidding.biddingId}`)
break
case 'pre-quote':
// 사전견적 페이지로 이동
- router.push(`/partners/bid/${rowAction.row.original.biddingId}/pre-quote`)
- break
- case 'participation':
- // 입찰 참여 의사 결정 다이얼로그 열기 - 상세 데이터 로드 필요
- handlePreQuoteParticipationDecision(true)
- setRowAction(null) // rowAction 초기화
+ // 사전견적에 초대받지 않은 벤더는 bidding status가 bidding_opened 이고 isPreQuoteParticipated이 null일 경우, 초대받지 않은 것으로 판단
+ if (bidding.status === 'bidding_opened' && bidding.isPreQuoteParticipated === null) {
+ toast({
+ title: '접근 제한',
+ description: '사전견적에 초대받지 않은 업체입니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ router.push(`/partners/bid/${bidding.biddingId}/pre-quote`)
break
case 'view-documents':
// 첨부파일 다이얼로그 열기
@@ -137,7 +83,7 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
break
}
}
- }, [rowAction, router, handlePreQuoteParticipationDecision])
+ }, [rowAction, router, toast])
const columns = React.useMemo(
() => getPartnersBiddingListColumns({ setRowAction }),
@@ -199,9 +145,9 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
]
const { table } = useDataTable({
- data,
+ data: biddingData,
columns,
- pageCount,
+ pageCount: 1,
filterFields,
enableAdvancedFilter: true,
initialState: {
@@ -213,16 +159,6 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
clearOnDefault: true,
})
- if (isLoading) {
- return (
- <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>
- )
- }
return (
<>
@@ -232,12 +168,12 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
filterFields={advancedFilterFields}
shallow={false}
>
- <PartnersBiddingToolbarActions table={table} companyId={companyId} onRefresh={refreshData} setRowAction={setRowAction} />
+ <PartnersBiddingToolbarActions table={table} setRowAction={setRowAction} />
</DataTableAdvancedToolbar>
</DataTable>
- <PartnersBiddingAttendanceDialog
- open={rowAction?.type === "attendance"}
+ <PartnersSpecificationMeetingDialog
+ open={rowAction?.type === "specification_meeting"}
onOpenChange={() => setRowAction(null)}
biddingDetail={rowAction?.row.original ? {
id: rowAction.row.original.biddingId,
@@ -246,23 +182,11 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
preQuoteDate: null,
biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null,
evaluationDate: null,
- hasSpecificationMeeting: (rowAction.row.original as any).hasSpecificationMeeting || false, // 사양설명회 여부 추가
+ hasSpecificationMeeting: rowAction.row.original.hasSpecificationMeeting || false,
} : null}
biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0}
isAttending={rowAction?.row.original?.isAttendingMeeting || null}
- onSuccess={refreshData}
/>
-{/*
- <PartnersBiddingParticipationDialog
- open={isParticipationDialogOpen}
- onOpenChange={setIsParticipationDialogOpen}
- bidding={selectedBiddingForParticipation}
- companyId={companyId}
- onSuccess={() => {
- refreshData()
- setSelectedBiddingForParticipation(null)
- }}
- /> */}
<PartnersBiddingAttachmentsDialog
open={isAttachmentsDialogOpen}
diff --git a/lib/bidding/vendor/partners-bidding-participation-dialog.tsx b/lib/bidding/vendor/partners-bidding-participation-dialog.tsx
deleted file mode 100644
index e2376863..00000000
--- a/lib/bidding/vendor/partners-bidding-participation-dialog.tsx
+++ /dev/null
@@ -1,248 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Badge } from '@/components/ui/badge'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { CheckCircle, XCircle, AlertCircle, Calendar, Package } from 'lucide-react'
-import { PartnersBiddingListItem } from '../detail/service'
-import { respondToPreQuoteInvitation, getBiddingCompaniesForPartners } from '../pre-quote/service'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { formatDate } from '@/lib/utils'
-
-interface PartnersBiddingParticipationDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- bidding: PartnersBiddingListItem | null
- companyId: number
- onSuccess: () => void
-}
-
-export function PartnersBiddingParticipationDialog({
- open,
- onOpenChange,
- bidding,
- companyId,
- onSuccess
-}: PartnersBiddingParticipationDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [selectedResponse, setSelectedResponse] = React.useState<'accepted' | 'declined' | null>(null)
-
- const handleSubmit = () => {
- if (!bidding || !selectedResponse) {
- toast({
- title: '오류',
- description: '참여 의사를 선택해주세요.',
- variant: 'destructive',
- })
- return
- }
-
- startTransition(async () => {
- try {
- // 먼저 해당 업체의 biddingCompanyId를 조회
- const biddingCompanyData = await getBiddingCompaniesForPartners(bidding.biddingId, companyId)
-
- if (!biddingCompanyData || !biddingCompanyData.biddingCompanyId) {
- toast({
- title: '오류',
- description: '입찰 업체 정보를 찾을 수 없습니다.',
- variant: 'destructive',
- })
- return
- }
-
- const result = await respondToPreQuoteInvitation(
- biddingCompanyData.biddingCompanyId,
- selectedResponse
- )
-
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
- setSelectedResponse(null)
- onOpenChange(false)
- onSuccess()
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
- })
- }
- } catch (error) {
- toast({
- title: '오류',
- description: '처리 중 오류가 발생했습니다.',
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleOpenChange = (open: boolean) => {
- onOpenChange(open)
- if (!open) {
- setSelectedResponse(null)
- }
- }
-
- if (!bidding) return null
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <AlertCircle className="w-5 h-5" />
- 사전견적 참여 의사 결정
- </DialogTitle>
- <DialogDescription>
- 아래 입찰건에 대한 사전견적 참여 여부를 결정해주세요.
- </DialogDescription>
- </DialogHeader>
-
- <div className="py-4">
- {/* 입찰 정보 카드 */}
- <Card className="mb-6">
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <Package className="w-5 h-5" />
- 입찰 정보
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="space-y-3">
- <div>
- <strong>입찰번호:</strong> {bidding.biddingNumber}
- {bidding.revision > 0 && (
- <Badge variant="outline" className="ml-2">
- Rev.{bidding.revision}
- </Badge>
- )}
- </div>
- <div>
- <strong>입찰명:</strong> {bidding.title}
- </div>
- <div>
- <strong>품목명:</strong> {bidding.itemName}
- </div>
- <div>
- <strong>프로젝트:</strong> {bidding.projectName}
- </div>
- {bidding.preQuoteDate && (
- <div className="flex items-center gap-2">
- <Calendar className="w-4 h-4" />
- <strong>사전견적 마감일:</strong>
- <span className="text-red-600 font-semibold">
- {formatDate(bidding.preQuoteDate, 'KR')}
- </span>
- </div>
- )}
- <div>
- <strong>담당자:</strong> {bidding.managerName}
- </div>
- </div>
- </CardContent>
- </Card>
-
- {/* 참여 의사 선택 */}
- <div className="space-y-4">
- <h3 className="text-lg font-semibold">참여 의사를 선택해주세요:</h3>
-
- <div className="grid grid-cols-2 gap-4">
- {/* 참여 수락 */}
- <Card
- className={`cursor-pointer transition-all ${
- selectedResponse === 'accepted'
- ? 'ring-2 ring-green-500 bg-green-50'
- : 'hover:shadow-md'
- }`}
- onClick={() => setSelectedResponse('accepted')}
- >
- <CardContent className="p-6 text-center">
- <CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-4" />
- <h4 className="text-lg font-semibold text-green-700 mb-2">
- 참여 수락
- </h4>
- <p className="text-sm text-gray-600">
- 사전견적에 참여하겠습니다.
- </p>
- </CardContent>
- </Card>
-
- {/* 참여 거절 */}
- <Card
- className={`cursor-pointer transition-all ${
- selectedResponse === 'declined'
- ? 'ring-2 ring-red-500 bg-red-50'
- : 'hover:shadow-md'
- }`}
- onClick={() => setSelectedResponse('declined')}
- >
- <CardContent className="p-6 text-center">
- <XCircle className="w-12 h-12 text-red-600 mx-auto mb-4" />
- <h4 className="text-lg font-semibold text-red-700 mb-2">
- 참여 거절
- </h4>
- <p className="text-sm text-gray-600">
- 사전견적에 참여하지 않겠습니다.
- </p>
- </CardContent>
- </Card>
- </div>
-
- {selectedResponse && (
- <div className="mt-4 p-4 rounded-lg bg-blue-50 border border-blue-200">
- <div className="flex items-center gap-2">
- <AlertCircle className="w-5 h-5 text-blue-600" />
- <span className="font-medium text-blue-800">
- {selectedResponse === 'accepted'
- ? '참여 수락을 선택하셨습니다.'
- : '참여 거절을 선택하셨습니다.'
- }
- </span>
- </div>
- <p className="text-sm text-blue-600 mt-1">
- {selectedResponse === 'accepted'
- ? '수락 후 사전견적서를 작성하실 수 있습니다.'
- : '거절 후에는 이 입찰건에 참여할 수 없습니다.'
- }
- </p>
- </div>
- )}
- </div>
- </div>
-
- <DialogFooter>
- <Button variant="outline" onClick={() => handleOpenChange(false)}>
- 취소
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isPending || !selectedResponse}
- className={selectedResponse === 'accepted' ? 'bg-green-600 hover:bg-green-700' :
- selectedResponse === 'declined' ? 'bg-red-600 hover:bg-red-700' : ''}
- >
- {selectedResponse === 'accepted' && <CheckCircle className="w-4 h-4 mr-2" />}
- {selectedResponse === 'declined' && <XCircle className="w-4 h-4 mr-2" />}
- {selectedResponse === 'accepted' ? '참여 수락' :
- selectedResponse === 'declined' ? '참여 거절' : '선택하세요'}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
index 4ec65413..6a76ffa1 100644
--- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx
+++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
@@ -10,12 +10,18 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
ArrowLeft,
Calendar,
Building2,
Package,
User,
- DollarSign,
FileText,
Users,
Send,
@@ -35,12 +41,11 @@ import {
} from '../pre-quote/service'
import { getBiddingConditions } from '../service'
import { getPriceAdjustmentFormByBiddingCompanyId } from '../detail/service'
+import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from '@/lib/procurement-select/service'
import { PrItemsPricingTable } from './components/pr-items-pricing-table'
import { SimpleFileUpload } from './components/simple-file-upload'
import {
biddingStatusLabels,
- contractTypeLabels,
- biddingTypeLabels
} from '@/db/schema'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
@@ -63,7 +68,8 @@ interface BiddingDetail {
contractType: string
biddingType: string
awardCount: string
- contractPeriod: string | null
+ contractStartDate: Date | null
+ contractEndDate: Date | null
preQuoteDate: string | null
biddingRegistrationDate: string | null
submissionStartDate: string | null
@@ -105,6 +111,12 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null)
const [isLoading, setIsLoading] = React.useState(true)
const [biddingConditions, setBiddingConditions] = React.useState<any | null>(null)
+
+ // Procurement 데이터 상태들
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{code: string, description: string}>>([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<Array<{code: string, description: string}>>([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{code: string, description: string}>>([])
// 품목별 견적 관련 상태
const [prItems, setPrItems] = React.useState<any[]>([])
@@ -151,6 +163,43 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
})
const userId = session.data?.user?.id || ''
+ // Procurement 데이터 로드 함수들
+ const loadPaymentTerms = React.useCallback(async () => {
+ try {
+ const data = await getPaymentTermsForSelection();
+ setPaymentTermsOptions(data);
+ } catch (error) {
+ console.error("Failed to load payment terms:", error);
+ }
+ }, []);
+
+ const loadIncoterms = React.useCallback(async () => {
+ try {
+ const data = await getIncotermsForSelection();
+ setIncotermsOptions(data);
+ } catch (error) {
+ console.error("Failed to load incoterms:", error);
+ }
+ }, []);
+
+ const loadShippingPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfShippingForSelection();
+ setShippingPlaces(data);
+ } catch (error) {
+ console.error("Failed to load shipping places:", error);
+ }
+ }, []);
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfDestinationForSelection();
+ setDestinationPlaces(data);
+ } catch (error) {
+ console.error("Failed to load destination places:", error);
+ }
+ }, []);
+
// 데이터 로드
React.useEffect(() => {
const loadData = async () => {
@@ -229,6 +278,14 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
if (prItemsData) {
setPrItems(prItemsData)
}
+
+ // Procurement 데이터 로드
+ await Promise.all([
+ loadPaymentTerms(),
+ loadIncoterms(),
+ loadShippingPlaces(),
+ loadDestinationPlaces()
+ ])
} catch (error) {
console.error('Failed to load bidding company:', error)
toast({
@@ -242,7 +299,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
}
loadData()
- }, [biddingId, companyId, toast])
+ }, [biddingId, companyId, toast, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
// 임시저장 기능
const handleTempSave = () => {
@@ -428,7 +485,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
{ value: responseData.taxConditionsResponse, name: '응답 세금조건' },
{ value: responseData.incotermsResponse, name: '응답 운송조건' },
{ value: responseData.proposedShippingPort, name: '제안 선적지' },
- { value: responseData.proposedDestinationPort, name: '제안 도착지' },
+ { value: responseData.proposedDestinationPort, name: '제안 하역지' },
{ value: responseData.sparePartResponse, name: '스페어파트 응답' },
]
@@ -775,7 +832,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
</div>
<div>
- <Label className="text-muted-foreground">도착지</Label>
+ <Label className="text-muted-foreground">하역지</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
<p className="font-medium">{biddingConditions.destinationPort || "미설정"}</p>
</div>
@@ -849,12 +906,13 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
</span>
</div>
{participationDecision === false && (
+ <>
<div className="p-4 bg-muted rounded-lg">
<p className="text-muted-foreground">
미참여로 설정되어 견적 작성 섹션이 숨겨집니다. 참여하시려면 아래 버튼을 클릭해주세요.
</p>
</div>
- )}
+
<Button
variant="outline"
size="sm"
@@ -863,6 +921,8 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin
>
결정 변경하기
</Button>
+ </>
+ )}
</div>
)}
</CardContent>
@@ -961,12 +1021,27 @@ 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">응답 지급조건 <span className="text-red-500">*</span></Label>
- <Input
- id="paymentTermsResponse"
+ <Select
value={responseData.paymentTermsResponse}
- onChange={(e) => setResponseData({...responseData, paymentTermsResponse: e.target.value})}
- placeholder={biddingConditions?.paymentTerms ? `참고: ${biddingConditions.paymentTerms}` : "지급조건에 대한 의견을 입력하세요"}
- />
+ onValueChange={(value) => setResponseData({...responseData, paymentTermsResponse: value})}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={biddingConditions?.paymentTerms ? `참고: ${biddingConditions.paymentTerms}` : "지급조건 선택"} />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</div>
<div className="space-y-2">
@@ -983,34 +1058,79 @@ 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">응답 운송조건 <span className="text-red-500">*</span></Label>
- <Input
- id="incotermsResponse"
+ <Select
value={responseData.incotermsResponse}
- onChange={(e) => setResponseData({...responseData, incotermsResponse: e.target.value})}
- placeholder={biddingConditions?.incoterms ? `참고: ${biddingConditions.incoterms}` : "운송조건에 대한 의견을 입력하세요"}
- />
+ onValueChange={(value) => setResponseData({...responseData, incotermsResponse: value})}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={biddingConditions?.incoterms ? `참고: ${biddingConditions.incoterms}` : "운송조건 선택"} />
+ </SelectTrigger>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</div>
<div className="space-y-2">
<Label htmlFor="proposedShippingPort">제안 선적지 <span className="text-red-500">*</span></Label>
- <Input
- id="proposedShippingPort"
+ <Select
value={responseData.proposedShippingPort}
- onChange={(e) => setResponseData({...responseData, proposedShippingPort: e.target.value})}
- placeholder={biddingConditions?.shippingPort ? `참고: ${biddingConditions.shippingPort}` : "선적지를 입력하세요"}
- />
+ onValueChange={(value) => setResponseData({...responseData, proposedShippingPort: value})}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={biddingConditions?.shippingPort ? `참고: ${biddingConditions.shippingPort}` : "선적지 선택"} />
+ </SelectTrigger>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
- <Label htmlFor="proposedDestinationPort">제안 도착지 <span className="text-red-500">*</span></Label>
- <Input
- id="proposedDestinationPort"
+ <Label htmlFor="proposedDestinationPort">제안 하역지 <span className="text-red-500">*</span></Label>
+ <Select
value={responseData.proposedDestinationPort}
- onChange={(e) => setResponseData({...responseData, proposedDestinationPort: e.target.value})}
- placeholder={biddingConditions?.destinationPort ? `참고: ${biddingConditions.destinationPort}` : "도착지를 입력하세요"}
- />
+ onValueChange={(value) => setResponseData({...responseData, proposedDestinationPort: value})}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder={biddingConditions?.destinationPort ? `참고: ${biddingConditions.destinationPort}` : "하역지 선택"} />
+ </SelectTrigger>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</div>
<div className="space-y-2">
diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
index 1500f6a7..87b1367e 100644
--- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
+++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
@@ -2,45 +2,39 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Users, CheckCircle, XCircle } from "lucide-react"
+import { Users} from "lucide-react"
import { Button } from "@/components/ui/button"
import { PartnersBiddingListItem } from '../detail/service'
interface PartnersBiddingToolbarActionsProps {
table: Table<PartnersBiddingListItem>
- companyId: number
- onRefresh: () => void
setRowAction?: (action: { type: string; row: { original: PartnersBiddingListItem } }) => void
}
export function PartnersBiddingToolbarActions({
- table,
- companyId,
- onRefresh,
+ table,
setRowAction
}: PartnersBiddingToolbarActionsProps) {
// 선택된 행 가져오기 (단일 선택)
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null
- const handleAttendanceClick = () => {
+ const handleSpecificationMeetingClick = () => {
if (selectedBidding && setRowAction) {
setRowAction({
- type: 'attendance',
+ type: 'specification_meeting',
row: { original: selectedBidding }
})
}
}
-
-
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
- onClick={handleAttendanceClick}
+ onClick={handleSpecificationMeetingClick}
className="flex items-center gap-2"
>
<Users className="w-4 h-4" />