diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-17 10:40:12 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-17 10:40:12 +0000 |
| commit | 10cb50753ccf318024c4394282f9e8d968dcd1a5 (patch) | |
| tree | cf4edb96aa172c3b90d88532aff1f536944a2283 /lib/bidding | |
| parent | f7117370b9cc0c7b96bd1eb23a1b9f5b16cc8ceb (diff) | |
(최겸) 구매 입찰 오류 수정 및 선적지,하역지 연동,TO Cont, TO PO 개발
Diffstat (limited to 'lib/bidding')
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" /> |
