summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-02 10:30:58 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-02 10:30:58 +0000
commit581b415e6707d9f1d0d0b667b84c4314461bfe37 (patch)
tree5476543a290ada5c3f29a0cba24ee86fc9c215b2
parentd5ddafa4276b0031538261400e431009b0734be9 (diff)
(최겸) 입찰 등록, 협력업체 응찰 기능 개발
-rw-r--r--app/[lng]/partners/(partners)/bid/[id]/page.tsx9
-rw-r--r--lib/bidding/detail/service.ts326
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx101
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx155
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx153
-rw-r--r--lib/bidding/vendor/partners-bidding-attendance-dialog.tsx381
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx176
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx83
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx47
-rw-r--r--lib/bidding/vendor/partners-bidding-toolbar-actions.tsx55
-rw-r--r--lib/mail/templates/specification-meeting-attendance.hbs92
11 files changed, 1118 insertions, 460 deletions
diff --git a/app/[lng]/partners/(partners)/bid/[id]/page.tsx b/app/[lng]/partners/(partners)/bid/[id]/page.tsx
index 8b1f346d..b8c7ea59 100644
--- a/app/[lng]/partners/(partners)/bid/[id]/page.tsx
+++ b/app/[lng]/partners/(partners)/bid/[id]/page.tsx
@@ -6,13 +6,14 @@ import { getServerSession } from 'next-auth'
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
interface PartnersBidDetailPageProps {
- params: {
+ params: Promise<{
id: string
- }
+ }>
}
-export default async function PartnersBidDetailPage({ params }: PartnersBidDetailPageProps) {
- const biddingId = parseInt(params.id)
+export default async function PartnersBidDetailPage(props: PartnersBidDetailPageProps) {
+ const resolvedParams = await props.params
+ const biddingId = parseInt(resolvedParams.id)
if (isNaN(biddingId)) {
return (
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index d0dc6a08..2ce17713 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -62,21 +62,20 @@ export interface QuotationVendor {
contactPhone: string
quotationAmount: number // 견적금액
currency: string
- paymentTerms: string // 지급조건 (응답)
- taxConditions: string // 세금조건 (응답)
- deliveryDate: string // 납품일 (응답)
submissionDate: string // 제출일
isWinner: boolean // 낙찰여부
awardRatio: number // 발주비율
status: 'pending' | 'submitted' | 'selected' | 'rejected'
- // bidding_conditions에서 제시된 조건들
- offeredPaymentTerms?: string // 제시된 지급조건
- offeredTaxConditions?: string // 제시된 세금조건
- offeredIncoterms?: string // 제시된 운송조건
- offeredContractDeliveryDate?: string // 제시된 계약납기일
- offeredShippingPort?: string // 제시된 선적지
- offeredDestinationPort?: string // 제시된 도착지
- isPriceAdjustmentApplicable?: boolean // 연동제 적용 여부
+ // companyConditionResponses에서 가져온 입찰 조건들
+ paymentTermsResponse?: string // 지급조건 응답
+ taxConditionsResponse?: string // 세금조건 응답
+ incotermsResponse?: string // 운송조건 응답
+ proposedContractDeliveryDate?: string // 제안 계약납기일
+ proposedShippingPort?: string // 제안 선적지
+ proposedDestinationPort?: string // 제안 도착지
+ priceAdjustmentResponse?: boolean // 연동제 적용 응답
+ sparePartResponse?: string // 스페어파트 응답
+ additionalProposals?: string // 추가 제안사항
documents: Array<{
id: number
fileName: string
@@ -195,7 +194,7 @@ export async function getPRItemsForBidding(biddingId: number) {
// 견적 시스템에서 협력업체 정보를 가져오는 함수
export async function getQuotationVendors(biddingId: number): Promise<QuotationVendor[]> {
try {
- // bidding_companies 테이블을 메인으로 vendors, bidding_conditions, company_condition_responses를 조인하여 협력업체 정보 조회
+ // bidding_companies 테이블을 메인으로 vendors, company_condition_responses를 조인하여 협력업체 정보 조회
const vendorsData = await db
.select({
id: biddingCompanies.id,
@@ -208,9 +207,6 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
contactPhone: biddingCompanies.contactPhone,
quotationAmount: biddingCompanies.finalQuoteAmount,
currency: sql<string>`'KRW'` as currency,
- paymentTerms: sql<string>`COALESCE(${companyConditionResponses.paymentTermsResponse}, '')`,
- taxConditions: sql<string>`COALESCE(${companyConditionResponses.taxConditionsResponse}, '')`,
- deliveryDate: companyConditionResponses.proposedContractDeliveryDate,
submissionDate: biddingCompanies.finalQuoteSubmittedAt,
isWinner: biddingCompanies.isWinner,
awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`,
@@ -220,19 +216,20 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
WHEN ${biddingCompanies.respondedAt} IS NOT NULL THEN 'submitted'
ELSE 'pending'
END`,
- // bidding_conditions에서 제시된 조건들
- offeredPaymentTerms: biddingConditions.paymentTerms,
- offeredTaxConditions: biddingConditions.taxConditions,
- offeredIncoterms: biddingConditions.incoterms,
- offeredContractDeliveryDate: biddingConditions.contractDeliveryDate,
- offeredShippingPort: biddingConditions.shippingPort,
- offeredDestinationPort: biddingConditions.destinationPort,
- isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable,
+ // companyConditionResponses에서 입찰 조건들
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
+ additionalProposals: companyConditionResponses.additionalProposals,
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
.leftJoin(companyConditionResponses, eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId))
- .leftJoin(biddingConditions, eq(biddingCompanies.id, biddingConditions.biddingCompanyId))
.where(eq(biddingCompanies.biddingId, biddingId))
.orderBy(desc(biddingCompanies.finalQuoteAmount))
@@ -247,21 +244,20 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
contactPhone: vendor.contactPhone || '',
quotationAmount: Number(vendor.quotationAmount) || 0,
currency: vendor.currency,
- paymentTerms: vendor.paymentTerms,
- taxConditions: vendor.taxConditions,
- deliveryDate: vendor.deliveryDate ? vendor.deliveryDate.toISOString().split('T')[0] : '',
submissionDate: vendor.submissionDate ? vendor.submissionDate.toISOString().split('T')[0] : '',
isWinner: vendor.isWinner || false,
awardRatio: vendor.awardRatio || 0,
status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected',
- // bidding_conditions에서 제시된 조건들
- offeredPaymentTerms: vendor.offeredPaymentTerms,
- offeredTaxConditions: vendor.offeredTaxConditions,
- offeredIncoterms: vendor.offeredIncoterms,
- offeredContractDeliveryDate: vendor.offeredContractDeliveryDate ? vendor.offeredContractDeliveryDate.toISOString().split('T')[0] : undefined,
- offeredShippingPort: vendor.offeredShippingPort,
- offeredDestinationPort: vendor.offeredDestinationPort,
- isPriceAdjustmentApplicable: vendor.isPriceAdjustmentApplicable,
+ // companyConditionResponses에서 입찰 조건들
+ paymentTermsResponse: vendor.paymentTermsResponse || '',
+ taxConditionsResponse: vendor.taxConditionsResponse || '',
+ incotermsResponse: vendor.incotermsResponse || '',
+ proposedContractDeliveryDate: vendor.proposedContractDeliveryDate ? (typeof vendor.proposedContractDeliveryDate === 'string' ? vendor.proposedContractDeliveryDate : vendor.proposedContractDeliveryDate.toISOString().split('T')[0]) : undefined,
+ proposedShippingPort: vendor.proposedShippingPort || '',
+ proposedDestinationPort: vendor.proposedDestinationPort || '',
+ priceAdjustmentResponse: vendor.priceAdjustmentResponse || false,
+ sparePartResponse: vendor.sparePartResponse || '',
+ additionalProposals: vendor.additionalProposals || '',
documents: [] // TODO: 문서 정보 조회 로직 추가
}))
} catch (error) {
@@ -295,15 +291,14 @@ export async function updateTargetPrice(
}
}
-// 협력업체 정보 저장 - biddingCompanies와 biddingConditions 테이블에 레코드 생성
-export async function createQuotationVendor(input: Omit<QuotationVendor, 'id'>, userId: string) {
+// 협력업체 정보 저장 - biddingCompanies와 companyConditionResponses 테이블에 레코드 생성
+export async function createQuotationVendor(input: any, userId: string) {
try {
const result = await db.transaction(async (tx) => {
// 1. biddingCompanies에 레코드 생성
const biddingCompanyResult = await tx.insert(biddingCompanies).values({
biddingId: input.biddingId,
companyId: input.vendorId,
- vendorId: input.vendorId,
quotationAmount: input.quotationAmount,
currency: input.currency,
status: input.status,
@@ -312,9 +307,6 @@ export async function createQuotationVendor(input: Omit<QuotationVendor, 'id'>,
contactPerson: input.contactPerson,
contactEmail: input.contactEmail,
contactPhone: input.contactPhone,
- paymentTerms: input.paymentTerms,
- taxConditions: input.taxConditions,
- deliveryDate: input.deliveryDate ? new Date(input.deliveryDate) : null,
submissionDate: new Date(),
createdBy: userId,
updatedBy: userId,
@@ -326,17 +318,20 @@ export async function createQuotationVendor(input: Omit<QuotationVendor, 'id'>,
const biddingCompanyId = biddingCompanyResult[0].id
- // 2. biddingConditions에 기본 조건 생성
- await tx.insert(biddingConditions).values({
+ // 2. companyConditionResponses에 입찰 조건 생성
+ await tx.insert(companyConditionResponses).values({
biddingCompanyId: biddingCompanyId,
- paymentTerms: '["선금 30%, 잔금 70%"]', // 기본 지급조건
- taxConditions: '["부가세 별도"]', // 기본 세금조건
- contractDeliveryDate: null,
- isPriceAdjustmentApplicable: false,
- incoterms: '["FOB"]', // 기본 운송조건
- shippingPort: null,
- destinationPort: null,
- sparePartOptions: '[]', // 기본 예비품 옵션
+ paymentTermsResponse: input.paymentTermsResponse || '',
+ taxConditionsResponse: input.taxConditionsResponse || '',
+ proposedContractDeliveryDate: input.proposedContractDeliveryDate ? new Date(input.proposedContractDeliveryDate) : null,
+ priceAdjustmentResponse: input.priceAdjustmentResponse || false,
+ incotermsResponse: input.incotermsResponse || '',
+ proposedShippingPort: input.proposedShippingPort || '',
+ proposedDestinationPort: input.proposedDestinationPort || '',
+ sparePartResponse: input.sparePartResponse || '',
+ additionalProposals: input.additionalProposals || '',
+ isPreQuote: false,
+ submittedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
})
@@ -357,7 +352,7 @@ export async function createQuotationVendor(input: Omit<QuotationVendor, 'id'>,
}
// 협력업체 정보 업데이트
-export async function updateQuotationVendor(id: number, input: Partial<QuotationVendor>, userId: string) {
+export async function updateQuotationVendor(id: number, input: any, userId: string) {
try {
const result = await db.transaction(async (tx) => {
// 1. biddingCompanies 테이블 업데이트
@@ -377,28 +372,32 @@ export async function updateQuotationVendor(id: number, input: Partial<Quotation
.where(eq(biddingCompanies.id, id))
}
- // 2. biddingConditions 테이블 업데이트 (제시된 조건들)
- if (input.offeredPaymentTerms !== undefined ||
- input.offeredTaxConditions !== undefined ||
- input.offeredIncoterms !== undefined ||
- input.offeredContractDeliveryDate !== undefined ||
- input.offeredShippingPort !== undefined ||
- input.offeredDestinationPort !== undefined ||
- input.isPriceAdjustmentApplicable !== undefined) {
+ // 2. companyConditionResponses 테이블 업데이트 (입찰 조건들)
+ if (input.paymentTermsResponse !== undefined ||
+ input.taxConditionsResponse !== undefined ||
+ input.incotermsResponse !== undefined ||
+ input.proposedContractDeliveryDate !== undefined ||
+ input.proposedShippingPort !== undefined ||
+ input.proposedDestinationPort !== undefined ||
+ input.priceAdjustmentResponse !== undefined ||
+ input.sparePartResponse !== undefined ||
+ input.additionalProposals !== undefined) {
const conditionsUpdateData: any = {}
- if (input.offeredPaymentTerms !== undefined) conditionsUpdateData.paymentTerms = input.offeredPaymentTerms
- if (input.offeredTaxConditions !== undefined) conditionsUpdateData.taxConditions = input.offeredTaxConditions
- if (input.offeredIncoterms !== undefined) conditionsUpdateData.incoterms = input.offeredIncoterms
- if (input.offeredContractDeliveryDate !== undefined) conditionsUpdateData.contractDeliveryDate = input.offeredContractDeliveryDate ? new Date(input.offeredContractDeliveryDate) : null
- if (input.offeredShippingPort !== undefined) conditionsUpdateData.shippingPort = input.offeredShippingPort
- if (input.offeredDestinationPort !== undefined) conditionsUpdateData.destinationPort = input.offeredDestinationPort
- if (input.isPriceAdjustmentApplicable !== undefined) conditionsUpdateData.isPriceAdjustmentApplicable = input.isPriceAdjustmentApplicable
+ if (input.paymentTermsResponse !== undefined) conditionsUpdateData.paymentTermsResponse = input.paymentTermsResponse
+ if (input.taxConditionsResponse !== undefined) conditionsUpdateData.taxConditionsResponse = input.taxConditionsResponse
+ if (input.incotermsResponse !== undefined) conditionsUpdateData.incotermsResponse = input.incotermsResponse
+ if (input.proposedContractDeliveryDate !== undefined) conditionsUpdateData.proposedContractDeliveryDate = input.proposedContractDeliveryDate ? new Date(input.proposedContractDeliveryDate) : null
+ if (input.proposedShippingPort !== undefined) conditionsUpdateData.proposedShippingPort = input.proposedShippingPort
+ if (input.proposedDestinationPort !== undefined) conditionsUpdateData.proposedDestinationPort = input.proposedDestinationPort
+ if (input.priceAdjustmentResponse !== undefined) conditionsUpdateData.priceAdjustmentResponse = input.priceAdjustmentResponse
+ if (input.sparePartResponse !== undefined) conditionsUpdateData.sparePartResponse = input.sparePartResponse
+ if (input.additionalProposals !== undefined) conditionsUpdateData.additionalProposals = input.additionalProposals
conditionsUpdateData.updatedAt = new Date()
- await tx.update(biddingConditions)
+ await tx.update(companyConditionResponses)
.set(conditionsUpdateData)
- .where(eq(biddingConditions.biddingCompanyId, id))
+ .where(eq(companyConditionResponses.biddingCompanyId, id))
}
return true
@@ -683,7 +682,7 @@ export interface PartnersBiddingListItem {
notes: string | null
createdAt: Date
updatedAt: Date
- updatedBy: string | null
+ // updatedBy: string | null
// biddings 정보
biddingId: number
@@ -725,7 +724,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
notes: biddingCompanies.notes,
createdAt: biddingCompanies.createdAt,
updatedAt: biddingCompanies.updatedAt,
- updatedBy: biddingCompanies.updatedBy,
+ // updatedBy: biddingCompanies.updatedBy, // 이 필드가 존재하지 않음
// biddings 정보
biddingId: biddings.id,
@@ -754,11 +753,13 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
))
.orderBy(desc(biddingCompanies.createdAt))
- // console.log(result)
+ console.log(result, "result")
// 계산된 필드 추가
const resultWithCalculatedFields = result.map(item => ({
...item,
+ respondedAt: item.respondedAt ? item.respondedAt.toISOString() : null,
+ finalQuoteAmount: item.finalQuoteAmount ? Number(item.finalQuoteAmount) : null, // string을 number로 변환
responseDeadline: item.submissionStartDate
? new Date(item.submissionStartDate.getTime() - 3 * 24 * 60 * 60 * 1000) // 3일 전
: null,
@@ -817,30 +818,22 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
isWinner: biddingCompanies.isWinner,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
- // 제시된 조건들 (bidding_conditions)
- offeredPaymentTerms: biddingConditions.paymentTerms,
- offeredTaxConditions: biddingConditions.taxConditions,
- offeredIncoterms: biddingConditions.incoterms,
- offeredContractDeliveryDate: biddingConditions.contractDeliveryDate,
- offeredShippingPort: biddingConditions.shippingPort,
- offeredDestinationPort: biddingConditions.destinationPort,
- isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable,
-
- // 응답한 조건들 (company_condition_responses)
- responsePaymentTerms: companyConditionResponses.paymentTermsResponse,
- responseTaxConditions: companyConditionResponses.taxConditionsResponse,
- responseIncoterms: companyConditionResponses.incotermsResponse,
+ // 응답한 조건들 (company_condition_responses) - 제시된 조건과 응답 모두 여기서 관리
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
proposedShippingPort: companyConditionResponses.proposedShippingPort,
proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
additionalProposals: companyConditionResponses.additionalProposals,
responseSubmittedAt: companyConditionResponses.submittedAt,
})
.from(biddings)
.innerJoin(biddingCompanies, eq(biddings.id, biddingCompanies.biddingId))
- .leftJoin(biddingConditions, eq(biddingCompanies.id, biddingConditions.biddingCompanyId))
.leftJoin(companyConditionResponses, eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId))
.where(and(
eq(biddings.id, biddingId),
@@ -866,6 +859,7 @@ export async function submitPartnerResponse(
proposedShippingPort?: string
proposedDestinationPort?: string
priceAdjustmentResponse?: boolean
+ sparePartResponse?: string
additionalProposals?: string
finalQuoteAmount?: number
},
@@ -878,10 +872,11 @@ export async function submitPartnerResponse(
paymentTermsResponse: response.paymentTermsResponse,
taxConditionsResponse: response.taxConditionsResponse,
incotermsResponse: response.incotermsResponse,
- proposedContractDeliveryDate: response.proposedContractDeliveryDate ? new Date(response.proposedContractDeliveryDate) : null,
+ proposedContractDeliveryDate: response.proposedContractDeliveryDate ? response.proposedContractDeliveryDate : null, // Date 대신 string 사용
proposedShippingPort: response.proposedShippingPort,
proposedDestinationPort: response.proposedDestinationPort,
priceAdjustmentResponse: response.priceAdjustmentResponse,
+ sparePartResponse: response.sparePartResponse,
additionalProposals: response.additionalProposals,
submittedAt: new Date(),
updatedAt: new Date(),
@@ -914,7 +909,7 @@ export async function submitPartnerResponse(
const companyUpdateData: any = {
respondedAt: new Date(),
updatedAt: new Date(),
- updatedBy: userId,
+ // updatedBy: userId, // 이 필드가 존재하지 않음
}
if (response.finalQuoteAmount !== undefined) {
@@ -931,7 +926,7 @@ export async function submitPartnerResponse(
return true
})
- revalidatePath(`/partners/bid/${biddingId}`)
+ revalidatePath('/partners/bid/[id]')
return {
success: true,
message: '응찰이 성공적으로 제출되었습니다.',
@@ -942,26 +937,157 @@ export async function submitPartnerResponse(
}
}
-// 사양설명회 참석 여부 업데이트
+// 사양설명회 정보 조회 (협력업체용)
+export async function getSpecificationMeetingForPartners(biddingId: number) {
+ try {
+ // bidding_documents에서 사양설명회 관련 문서 조회
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ filePath: biddingDocuments.filePath,
+ fileSize: biddingDocuments.fileSize,
+ title: biddingDocuments.title,
+ })
+ .from(biddingDocuments)
+ .where(and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.documentType, 'specification_meeting')
+ ))
+
+ // biddings 테이블에서 사양설명회 기본 정보 조회
+ const bidding = await db
+ .select({
+ id: biddings.id,
+ title: biddings.title,
+ biddingNumber: biddings.biddingNumber,
+ preQuoteDate: biddings.preQuoteDate,
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ managerName: biddings.managerName,
+ managerEmail: biddings.managerEmail,
+ managerPhone: biddings.managerPhone,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (bidding.length === 0) {
+ return { success: false, error: '입찰 정보를 찾을 수 없습니다.' }
+ }
+
+ return {
+ success: true,
+ data: {
+ ...bidding[0],
+ documents,
+ meetingDate: bidding[0].preQuoteDate ? bidding[0].preQuoteDate.toISOString().split('T')[0] : null,
+ contactPerson: bidding[0].managerName,
+ contactEmail: bidding[0].managerEmail,
+ contactPhone: bidding[0].managerPhone,
+ }
+ }
+ } catch (error) {
+ console.error('Failed to get specification meeting info:', error)
+ return { success: false, error: '사양설명회 정보 조회에 실패했습니다.' }
+ }
+}
+
+// 사양설명회 참석 여부 업데이트 (상세 정보 포함)
export async function updatePartnerAttendance(
biddingCompanyId: number,
- isAttending: boolean,
+ attendanceData: {
+ isAttending: boolean
+ attendeeCount?: number
+ representativeName?: string
+ representativePhone?: string
+ },
userId: string
) {
try {
- await db
- .update(biddingCompanies)
- .set({
- isAttendingMeeting: isAttending,
- updatedAt: new Date(),
- updatedBy: userId,
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
+ const result = await db.transaction(async (tx) => {
+ // biddingCompanies 테이블 업데이트 (참석여부만 저장)
+ await tx
+ .update(biddingCompanies)
+ .set({
+ isAttendingMeeting: attendanceData.isAttending,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ // 참석하는 경우, 사양설명회 담당자에게 이메일 발송을 위한 정보 반환
+ if (attendanceData.isAttending) {
+ const biddingInfo = await tx
+ .select({
+ biddingId: biddingCompanies.biddingId,
+ companyId: biddingCompanies.companyId,
+ managerEmail: biddings.managerEmail,
+ managerName: biddings.managerName,
+ title: biddings.title,
+ biddingNumber: biddings.biddingNumber,
+ })
+ .from(biddingCompanies)
+ .innerJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ .limit(1)
+
+ if (biddingInfo.length > 0) {
+ // 협력업체 정보 조회
+ const companyInfo = await tx
+ .select({
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, biddingInfo[0].companyId))
+ .limit(1)
+
+ const companyName = companyInfo.length > 0 ? companyInfo[0].vendorName : '알 수 없음'
+
+ // 메일 발송 (템플릿 사용)
+ try {
+ const { sendEmail } = await import('@/lib/mail/sendEmail')
+
+ await sendEmail({
+ to: biddingInfo[0].managerEmail,
+ template: 'specification-meeting-attendance',
+ context: {
+ biddingNumber: biddingInfo[0].biddingNumber,
+ title: biddingInfo[0].title,
+ companyName: companyName,
+ attendeeCount: attendanceData.attendeeCount,
+ representativeName: attendanceData.representativeName,
+ representativePhone: attendanceData.representativePhone,
+ managerName: biddingInfo[0].managerName,
+ managerEmail: biddingInfo[0].managerEmail,
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ }
+ })
+
+ console.log(`사양설명회 참석 알림 메일 발송 완료: ${biddingInfo[0].managerEmail}`)
+ } catch (emailError) {
+ console.error('메일 발송 실패:', emailError)
+ // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리
+ }
+
+ return {
+ ...biddingInfo[0],
+ companyName,
+ attendeeCount: attendanceData.attendeeCount,
+ representativeName: attendanceData.representativeName,
+ representativePhone: attendanceData.representativePhone
+ }
+ }
+ }
+
+ return null
+ })
revalidatePath('/partners/bid/[id]')
return {
success: true,
- message: `사양설명회 ${isAttending ? '참석' : '불참'}으로 설정되었습니다.`,
+ message: `사양설명회 ${attendanceData.isAttending ? '참석' : '불참'}으로 설정되었습니다.`,
+ data: result
}
} catch (error) {
console.error('Failed to update partner attendance:', error)
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index ef075459..9e06d5d1 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -114,69 +114,66 @@ export function getBiddingDetailVendorColumns({
),
},
{
- accessorKey: 'offeredPaymentTerms',
+ accessorKey: 'paymentTermsResponse',
header: '지급조건',
- cell: ({ row }) => {
- const terms = row.original.offeredPaymentTerms
- if (!terms) return <div className="text-muted-foreground">-</div>
-
- try {
- const parsed = JSON.parse(terms)
- return (
- <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}>
- {parsed.join(', ')}
- </div>
- )
- } catch {
- return <div className="text-sm max-w-32 truncate">{terms}</div>
- }
- },
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.paymentTermsResponse || ''}>
+ {row.original.paymentTermsResponse || '-'}
+ </div>
+ ),
},
{
- accessorKey: 'offeredTaxConditions',
+ accessorKey: 'taxConditionsResponse',
header: '세금조건',
- cell: ({ row }) => {
- const conditions = row.original.offeredTaxConditions
- if (!conditions) return <div className="text-muted-foreground">-</div>
-
- try {
- const parsed = JSON.parse(conditions)
- return (
- <div className="text-sm max-w-32 truncate" title={parsed.join(', ')}>
- {parsed.join(', ')}
- </div>
- )
- } catch {
- return <div className="text-sm max-w-32 truncate">{conditions}</div>
- }
- },
+ cell: ({ row }) => (
+ <div className="text-sm max-w-32 truncate" title={row.original.taxConditionsResponse || ''}>
+ {row.original.taxConditionsResponse || '-'}
+ </div>
+ ),
},
{
- accessorKey: 'offeredIncoterms',
+ accessorKey: 'incotermsResponse',
header: '운송조건',
- cell: ({ row }) => {
- const terms = row.original.offeredIncoterms
- if (!terms) return <div className="text-muted-foreground">-</div>
-
- try {
- const parsed = JSON.parse(terms)
- return (
- <div className="text-sm max-w-24 truncate" title={parsed.join(', ')}>
- {parsed.join(', ')}
- </div>
- )
- } catch {
- return <div className="text-sm max-w-24 truncate">{terms}</div>
- }
- },
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.incotermsResponse || ''}>
+ {row.original.incotermsResponse || '-'}
+ </div>
+ ),
},
{
- accessorKey: 'offeredContractDeliveryDate',
- header: '납품요청일',
+ accessorKey: 'proposedContractDeliveryDate',
+ header: '제안납기일',
cell: ({ row }) => (
<div className="text-sm">
- {row.original.offeredContractDeliveryDate ?
- new Date(row.original.offeredContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
+ {row.original.proposedContractDeliveryDate ?
+ new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'proposedShippingPort',
+ header: '제안선적지',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.proposedShippingPort || ''}>
+ {row.original.proposedShippingPort || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'proposedDestinationPort',
+ header: '제안도착지',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}>
+ {row.original.proposedDestinationPort || '-'}
+ </div>
+ ),
+ },
+ {
+ accessorKey: 'sparePartResponse',
+ header: '스페어파트',
+ cell: ({ row }) => (
+ <div className="text-sm max-w-24 truncate" title={row.original.sparePartResponse || ''}>
+ {row.original.sparePartResponse || '-'}
</div>
),
},
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
index 9229b09c..bd0f3684 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
@@ -4,6 +4,8 @@ import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
@@ -72,11 +74,18 @@ export function BiddingDetailVendorCreateDialog({
const [formData, setFormData] = React.useState({
quotationAmount: 0,
currency: 'KRW',
- paymentTerms: '',
- taxConditions: '',
- deliveryDate: '',
awardRatio: 0,
status: 'pending' as const,
+ // 입찰 조건 (companyConditionResponses 기반)
+ paymentTermsResponse: '',
+ taxConditionsResponse: '',
+ proposedContractDeliveryDate: '',
+ priceAdjustmentResponse: false,
+ incotermsResponse: '',
+ proposedShippingPort: '',
+ proposedDestinationPort: '',
+ sparePartResponse: '',
+ additionalProposals: '',
})
// Vendor 검색
@@ -163,11 +172,18 @@ export function BiddingDetailVendorCreateDialog({
setFormData({
quotationAmount: 0,
currency: 'KRW',
- paymentTerms: '',
- taxConditions: '',
- deliveryDate: '',
awardRatio: 0,
status: 'pending',
+ // 입찰 조건 초기화
+ paymentTermsResponse: '',
+ taxConditionsResponse: '',
+ proposedContractDeliveryDate: '',
+ priceAdjustmentResponse: false,
+ incotermsResponse: '',
+ proposedShippingPort: '',
+ proposedDestinationPort: '',
+ sparePartResponse: '',
+ additionalProposals: '',
})
}
@@ -291,34 +307,109 @@ export function BiddingDetailVendorCreateDialog({
</div>
</div>
- <div className="space-y-2">
- <Label htmlFor="paymentTerms">지급조건</Label>
- <Input
- id="paymentTerms"
- value={formData.paymentTerms}
- onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })}
- placeholder="지급조건을 입력하세요"
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="taxConditions">세금조건</Label>
- <Input
- id="taxConditions"
- value={formData.taxConditions}
- onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })}
- placeholder="세금조건을 입력하세요"
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="deliveryDate">납품일</Label>
- <Input
- id="deliveryDate"
- type="date"
- value={formData.deliveryDate}
- onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })}
- />
+ {/* 입찰 조건 섹션 */}
+ <div className="col-span-2 pt-4 border-t">
+ <h3 className="text-lg font-medium mb-4">입찰 조건 설정</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="paymentTermsResponse">지급조건</Label>
+ <Input
+ id="paymentTermsResponse"
+ value={formData.paymentTermsResponse}
+ onChange={(e) => setFormData({ ...formData, paymentTermsResponse: e.target.value })}
+ placeholder="지급조건을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="taxConditionsResponse">세금조건</Label>
+ <Input
+ id="taxConditionsResponse"
+ value={formData.taxConditionsResponse}
+ onChange={(e) => setFormData({ ...formData, taxConditionsResponse: e.target.value })}
+ placeholder="세금조건을 입력하세요"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 mt-4">
+ <div className="space-y-2">
+ <Label htmlFor="incotermsResponse">운송조건 (Incoterms)</Label>
+ <Input
+ id="incotermsResponse"
+ value={formData.incotermsResponse}
+ onChange={(e) => setFormData({ ...formData, incotermsResponse: e.target.value })}
+ placeholder="운송조건을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="proposedContractDeliveryDate">제안 계약납기일</Label>
+ <Input
+ id="proposedContractDeliveryDate"
+ type="date"
+ value={formData.proposedContractDeliveryDate}
+ onChange={(e) => setFormData({ ...formData, proposedContractDeliveryDate: e.target.value })}
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 mt-4">
+ <div className="space-y-2">
+ <Label htmlFor="proposedShippingPort">제안 선적지</Label>
+ <Input
+ id="proposedShippingPort"
+ value={formData.proposedShippingPort}
+ onChange={(e) => setFormData({ ...formData, proposedShippingPort: e.target.value })}
+ placeholder="선적지를 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="proposedDestinationPort">제안 도착지</Label>
+ <Input
+ id="proposedDestinationPort"
+ value={formData.proposedDestinationPort}
+ onChange={(e) => setFormData({ ...formData, proposedDestinationPort: e.target.value })}
+ placeholder="도착지를 입력하세요"
+ />
+ </div>
+ </div>
+
+ <div className="space-y-2 mt-4">
+ <Label htmlFor="sparePartResponse">스페어파트 응답</Label>
+ <Input
+ id="sparePartResponse"
+ value={formData.sparePartResponse}
+ onChange={(e) => setFormData({ ...formData, sparePartResponse: e.target.value })}
+ placeholder="스페어파트 관련 응답을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2 mt-4">
+ <Label htmlFor="additionalProposals">추가 제안사항</Label>
+ <Textarea
+ id="additionalProposals"
+ value={formData.additionalProposals}
+ onChange={(e) => setFormData({ ...formData, additionalProposals: e.target.value })}
+ placeholder="추가 제안사항을 입력하세요"
+ rows={3}
+ />
+ </div>
+
+ <div className="flex items-center space-x-2 mt-4">
+ <Checkbox
+ id="priceAdjustmentResponse"
+ checked={formData.priceAdjustmentResponse}
+ onCheckedChange={(checked) =>
+ setFormData({ ...formData, priceAdjustmentResponse: !!checked })
+ }
+ />
+ <Label htmlFor="priceAdjustmentResponse">연동제 적용</Label>
+ </div>
</div>
</div>
<DialogFooter>
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
index a48aadd2..75f53503 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx
@@ -4,6 +4,8 @@ import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
@@ -50,11 +52,18 @@ export function BiddingDetailVendorEditDialog({
contactPhone: '',
quotationAmount: 0,
currency: 'KRW',
- paymentTerms: '',
- taxConditions: '',
- deliveryDate: '',
awardRatio: 0,
status: 'pending' as const,
+ // 입찰 조건 (companyConditionResponses 기반)
+ paymentTermsResponse: '',
+ taxConditionsResponse: '',
+ proposedContractDeliveryDate: '',
+ priceAdjustmentResponse: false,
+ incotermsResponse: '',
+ proposedShippingPort: '',
+ proposedDestinationPort: '',
+ sparePartResponse: '',
+ additionalProposals: '',
})
// vendor가 변경되면 폼 데이터 업데이트
@@ -68,11 +77,18 @@ export function BiddingDetailVendorEditDialog({
contactPhone: vendor.contactPhone || '',
quotationAmount: vendor.quotationAmount,
currency: vendor.currency,
- paymentTerms: vendor.paymentTerms || '',
- taxConditions: vendor.taxConditions || '',
- deliveryDate: vendor.deliveryDate || '',
awardRatio: vendor.awardRatio || 0,
status: vendor.status,
+ // 입찰 조건 데이터 (vendor에서 가져오거나 기본값)
+ paymentTermsResponse: '',
+ taxConditionsResponse: '',
+ proposedContractDeliveryDate: '',
+ priceAdjustmentResponse: false,
+ incotermsResponse: '',
+ proposedShippingPort: '',
+ proposedDestinationPort: '',
+ sparePartResponse: '',
+ additionalProposals: '',
})
}
}, [vendor])
@@ -220,30 +236,107 @@ export function BiddingDetailVendorEditDialog({
</Select>
</div>
</div>
- <div className="space-y-2">
- <Label htmlFor="edit-paymentTerms">지급조건</Label>
- <Input
- id="edit-paymentTerms"
- value={formData.paymentTerms}
- onChange={(e) => setFormData({ ...formData, paymentTerms: e.target.value })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-taxConditions">세금조건</Label>
- <Input
- id="edit-taxConditions"
- value={formData.taxConditions}
- onChange={(e) => setFormData({ ...formData, taxConditions: e.target.value })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-deliveryDate">납품일</Label>
- <Input
- id="edit-deliveryDate"
- type="date"
- value={formData.deliveryDate}
- onChange={(e) => setFormData({ ...formData, deliveryDate: e.target.value })}
- />
+ {/* 입찰 조건 섹션 */}
+ <div className="col-span-2 pt-4 border-t">
+ <h3 className="text-lg font-medium mb-4">입찰 조건 설정</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-paymentTermsResponse">지급조건</Label>
+ <Input
+ id="edit-paymentTermsResponse"
+ value={formData.paymentTermsResponse}
+ onChange={(e) => setFormData({ ...formData, paymentTermsResponse: e.target.value })}
+ placeholder="지급조건을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="edit-taxConditionsResponse">세금조건</Label>
+ <Input
+ id="edit-taxConditionsResponse"
+ value={formData.taxConditionsResponse}
+ onChange={(e) => setFormData({ ...formData, taxConditionsResponse: e.target.value })}
+ placeholder="세금조건을 입력하세요"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 mt-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-incotermsResponse">운송조건 (Incoterms)</Label>
+ <Input
+ id="edit-incotermsResponse"
+ value={formData.incotermsResponse}
+ onChange={(e) => setFormData({ ...formData, incotermsResponse: e.target.value })}
+ placeholder="운송조건을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="edit-proposedContractDeliveryDate">제안 계약납기일</Label>
+ <Input
+ id="edit-proposedContractDeliveryDate"
+ type="date"
+ value={formData.proposedContractDeliveryDate}
+ onChange={(e) => setFormData({ ...formData, proposedContractDeliveryDate: e.target.value })}
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 mt-4">
+ <div className="space-y-2">
+ <Label htmlFor="edit-proposedShippingPort">제안 선적지</Label>
+ <Input
+ id="edit-proposedShippingPort"
+ value={formData.proposedShippingPort}
+ onChange={(e) => setFormData({ ...formData, proposedShippingPort: e.target.value })}
+ placeholder="선적지를 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="edit-proposedDestinationPort">제안 도착지</Label>
+ <Input
+ id="edit-proposedDestinationPort"
+ value={formData.proposedDestinationPort}
+ onChange={(e) => setFormData({ ...formData, proposedDestinationPort: e.target.value })}
+ placeholder="도착지를 입력하세요"
+ />
+ </div>
+ </div>
+
+ <div className="space-y-2 mt-4">
+ <Label htmlFor="edit-sparePartResponse">스페어파트 응답</Label>
+ <Input
+ id="edit-sparePartResponse"
+ value={formData.sparePartResponse}
+ onChange={(e) => setFormData({ ...formData, sparePartResponse: e.target.value })}
+ placeholder="스페어파트 관련 응답을 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2 mt-4">
+ <Label htmlFor="edit-additionalProposals">추가 제안사항</Label>
+ <Textarea
+ id="edit-additionalProposals"
+ value={formData.additionalProposals}
+ onChange={(e) => setFormData({ ...formData, additionalProposals: e.target.value })}
+ placeholder="추가 제안사항을 입력하세요"
+ rows={3}
+ />
+ </div>
+
+ <div className="flex items-center space-x-2 mt-4">
+ <Checkbox
+ id="edit-priceAdjustmentResponse"
+ checked={formData.priceAdjustmentResponse}
+ onCheckedChange={(checked) =>
+ setFormData({ ...formData, priceAdjustmentResponse: !!checked })
+ }
+ />
+ <Label htmlFor="edit-priceAdjustmentResponse">연동제 적용</Label>
+ </div>
</div>
</div>
<DialogFooter>
diff --git a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
index 270d9ccd..9205c46a 100644
--- a/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
+++ b/lib/bidding/vendor/partners-bidding-attendance-dialog.tsx
@@ -12,8 +12,10 @@ import {
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
-import { Textarea } from '@/components/ui/textarea'
+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,
@@ -21,11 +23,14 @@ import {
Clock,
FileText,
CheckCircle,
- XCircle
+ XCircle,
+ Download,
+ User,
+ Phone
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
-import { updatePartnerAttendance } from '../detail/service'
+import { updatePartnerAttendance, getSpecificationMeetingForPartners } from '../detail/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
@@ -55,8 +60,47 @@ export function PartnersBiddingAttendanceDialog({
}: PartnersBiddingAttendanceDialogProps) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [meetingData, setMeetingData] = React.useState<any>(null)
+
+ // 폼 상태
const [attendance, setAttendance] = React.useState<string>('')
- const [comments, setComments] = React.useState<string>('')
+ const [attendeeCount, setAttendeeCount] = React.useState<string>('')
+ const [representativeName, setRepresentativeName] = React.useState<string>('')
+ const [representativePhone, setRepresentativePhone] = React.useState<string>('')
+
+ // 사양설명회 정보 가져오기
+ React.useEffect(() => {
+ if (open && biddingDetail) {
+ fetchMeetingData()
+ }
+ }, [open, biddingDetail])
+
+ const fetchMeetingData = async () => {
+ if (!biddingDetail) return
+
+ setIsLoading(true)
+ try {
+ const result = await getSpecificationMeetingForPartners(biddingDetail.id)
+ if (result.success) {
+ setMeetingData(result.data)
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '사양설명회 정보를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
// 다이얼로그 열릴 때 기존 값으로 초기화
React.useEffect(() => {
@@ -68,7 +112,9 @@ export function PartnersBiddingAttendanceDialog({
} else {
setAttendance('')
}
- setComments('')
+ setAttendeeCount('')
+ setRepresentativeName('')
+ setRepresentativePhone('')
}
}, [open, isAttending])
@@ -82,10 +128,39 @@ export function PartnersBiddingAttendanceDialog({
return
}
+ // 참석하는 경우 필수 정보 체크
+ if (attendance === 'attending') {
+ if (!attendeeCount || !representativeName || !representativePhone) {
+ toast({
+ title: '필수 정보 누락',
+ description: '참석인원수, 참석자 대표 이름, 연락처를 모두 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ const countNum = parseInt(attendeeCount)
+ if (isNaN(countNum) || countNum < 1) {
+ toast({
+ title: '잘못된 입력',
+ description: '참석인원수는 1 이상의 숫자를 입력해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+ }
+
startTransition(async () => {
+ const attendanceData = {
+ isAttending: attendance === 'attending',
+ attendeeCount: attendance === 'attending' ? parseInt(attendeeCount) : undefined,
+ representativeName: attendance === 'attending' ? representativeName : undefined,
+ representativePhone: attendance === 'attending' ? representativePhone : undefined,
+ }
+
const result = await updatePartnerAttendance(
biddingCompanyId,
- attendance === 'attending',
+ attendanceData,
'current-user' // TODO: 실제 사용자 ID
)
@@ -94,6 +169,15 @@ export function PartnersBiddingAttendanceDialog({
title: '성공',
description: result.message,
})
+
+ // 참석하는 경우 이메일 발송 알림
+ if (attendance === 'attending' && result.data) {
+ toast({
+ title: '참석 알림 발송',
+ description: '사양설명회 담당자에게 참석 알림이 발송되었습니다.',
+ })
+ }
+
onSuccess()
onOpenChange(false)
} else {
@@ -106,129 +190,200 @@ export function PartnersBiddingAttendanceDialog({
})
}
+ const handleFileDownload = async (filePath: string, fileName: string) => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(filePath, fileName)
+ } catch (error) {
+ console.error('파일 다운로드 실패:', error)
+ toast({
+ title: '다운로드 실패',
+ description: '파일 다운로드 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
if (!biddingDetail) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[500px]">
+ <DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
- 사양설명회 참석 여부
+ 사양설명회
</DialogTitle>
<DialogDescription>
- 입찰에 대한 사양설명회 참석 여부를 선택해주세요.
+ {biddingDetail.title}의 사양설명회 정보 및 참석 여부를 확인해주세요.
</DialogDescription>
</DialogHeader>
- <div className="space-y-6">
- {/* 입찰 정보 요약 */}
- <div className="bg-muted p-4 rounded-lg space-y-3">
- <div className="flex items-center gap-2">
- <FileText className="w-4 h-4 text-muted-foreground" />
- <span className="font-medium">{biddingDetail.title}</span>
- </div>
- <div className="flex items-center gap-2">
- <Badge variant="outline" className="font-mono">
- {biddingDetail.biddingNumber}
- </Badge>
- </div>
-
- {/* 주요 일정 */}
- <div className="grid grid-cols-1 gap-2 text-sm">
- {biddingDetail.preQuoteDate && (
- <div className="flex items-center gap-2">
- <Calendar className="w-4 h-4 text-muted-foreground" />
- <span>사전견적 마감: {formatDate(biddingDetail.preQuoteDate, 'KR')}</span>
- </div>
- )}
- {biddingDetail.biddingRegistrationDate && (
- <div className="flex items-center gap-2">
- <Calendar className="w-4 h-4 text-muted-foreground" />
- <span>입찰등록 마감: {formatDate(biddingDetail.biddingRegistrationDate, 'KR')}</span>
- </div>
- )}
- {biddingDetail.evaluationDate && (
- <div className="flex items-center gap-2">
- <Calendar className="w-4 h-4 text-muted-foreground" />
- <span>평가일: {formatDate(biddingDetail.evaluationDate, 'KR')}</span>
- </div>
- )}
- </div>
- </div>
-
- {/* 참석 여부 선택 */}
- <div className="space-y-3">
- <Label className="text-base font-medium">참석 여부를 선택해주세요</Label>
- <RadioGroup
- value={attendance}
- onValueChange={setAttendance}
- className="space-y-3"
- >
- <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
- <RadioGroupItem value="attending" id="attending" />
- <div className="flex items-center gap-2 flex-1">
- <CheckCircle className="w-5 h-5 text-green-600" />
- <Label htmlFor="attending" className="font-medium cursor-pointer">
- 참석합니다
- </Label>
- </div>
- </div>
- <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
- <RadioGroupItem value="not_attending" id="not_attending" />
- <div className="flex items-center gap-2 flex-1">
- <XCircle className="w-5 h-5 text-red-600" />
- <Label htmlFor="not_attending" className="font-medium cursor-pointer">
- 참석하지 않습니다
- </Label>
- </div>
+ <ScrollArea className="max-h-[75vh]">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-6">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-2"></div>
+ <p className="text-sm text-muted-foreground">로딩 중...</p>
</div>
- </RadioGroup>
- </div>
-
- {/* 참석하지 않는 경우 의견 */}
- {attendance === 'not_attending' && (
- <div className="space-y-2">
- <Label htmlFor="comments">불참 사유 (선택사항)</Label>
- <Textarea
- id="comments"
- value={comments}
- onChange={(e) => setComments(e.target.value)}
- placeholder="참석하지 않는 이유를 간단히 설명해주세요."
- rows={3}
- className="resize-none"
- />
</div>
- )}
+ ) : (
+ <div className="space-y-6">
+ {/* 사양설명회 기본 정보 */}
+ {meetingData && (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base flex items-center gap-2">
+ <Calendar className="w-4 h-4" />
+ 설명회 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="pt-0">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <div className="text-sm space-y-2">
+ <div>
+ <span className="font-medium">설명회 일정:</span>
+ <span className="ml-2">
+ {meetingData.meetingDate ? formatDate(meetingData.meetingDate, 'KR') : '미정'}
+ </span>
+ </div>
+ <div>
+ <span className="font-medium">설명회 담당자:</span>
+ <div className="ml-2 text-muted-foreground">
+ {meetingData.contactPerson && (
+ <div>{meetingData.contactPerson}</div>
+ )}
+ {meetingData.contactEmail && (
+ <div>{meetingData.contactEmail}</div>
+ )}
+ {meetingData.contactPhone && (
+ <div>{meetingData.contactPhone}</div>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div>
+ <div className="text-sm">
+ <span className="font-medium">설명회 자료:</span>
+ <div className="mt-2 space-y-1">
+ {meetingData.documents && meetingData.documents.length > 0 ? (
+ meetingData.documents.map((doc: any) => (
+ <Button
+ key={doc.id}
+ variant="outline"
+ size="sm"
+ onClick={() => handleFileDownload(doc.filePath, doc.originalFileName)}
+ className="flex items-center gap-2 h-8 text-xs"
+ >
+ <Download className="w-3 h-3" />
+ {doc.originalFileName}
+ </Button>
+ ))
+ ) : (
+ <span className="text-muted-foreground">첨부파일 없음</span>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
- {/* 참석하는 경우 추가 정보 */}
- {attendance === 'attending' && (
- <div className="bg-green-50 border border-green-200 rounded-lg p-4">
- <div className="flex items-start gap-2">
- <CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
- <div className="space-y-1">
- <p className="font-medium text-green-800">참석 확인</p>
- <p className="text-sm text-green-700">
- 사양설명회에 참석하겠다고 응답하셨습니다.
- 회의 일정 및 장소는 추후 별도 안내드리겠습니다.
- </p>
- </div>
- </div>
- </div>
- )}
+ {/* 참석 여부 입력 폼 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">참석 여부</CardTitle>
+ </CardHeader>
+ <CardContent className="pt-0 space-y-4">
+ <RadioGroup
+ value={attendance}
+ onValueChange={setAttendance}
+ className="space-y-3"
+ >
+ <div className="flex items-start space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
+ <RadioGroupItem value="attending" id="attending" className="mt-1" />
+ <div className="flex-1">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="w-5 h-5 text-green-600" />
+ <Label htmlFor="attending" className="font-medium cursor-pointer">
+ Y (참석)
+ </Label>
+ </div>
+
+ {attendance === 'attending' && (
+ <div className="space-y-3 mt-3 pl-2">
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
+ <div>
+ <Label htmlFor="attendeeCount" className="text-xs">참석인원수</Label>
+ <Input
+ id="attendeeCount"
+ type="number"
+ min="1"
+ value={attendeeCount}
+ onChange={(e) => setAttendeeCount(e.target.value)}
+ placeholder="명"
+ className="h-8 text-sm"
+ />
+ </div>
+ <div>
+ <Label htmlFor="representativeName" className="text-xs">참석자 대표(이름)</Label>
+ <Input
+ id="representativeName"
+ value={representativeName}
+ onChange={(e) => setRepresentativeName(e.target.value)}
+ placeholder="홍길동 과장"
+ className="h-8 text-sm"
+ />
+ </div>
+ <div>
+ <Label htmlFor="representativePhone" className="text-xs">참석자 대표(연락처)</Label>
+ <Input
+ id="representativePhone"
+ value={representativePhone}
+ onChange={(e) => setRepresentativePhone(e.target.value)}
+ placeholder="010-0000-0000"
+ className="h-8 text-sm"
+ />
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors">
+ <RadioGroupItem value="not_attending" id="not_attending" />
+ <div className="flex items-center gap-2 flex-1">
+ <XCircle className="w-5 h-5 text-red-600" />
+ <Label htmlFor="not_attending" className="font-medium cursor-pointer">
+ N (불참)
+ </Label>
+ </div>
+ </div>
+ </RadioGroup>
+ </CardContent>
+ </Card>
- {/* 현재 상태 표시 */}
- {isAttending !== null && (
- <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
- <div className="flex items-center gap-2 text-blue-800">
- <Clock className="w-4 h-4" />
- <span className="text-sm">
- 현재 상태: {isAttending ? '참석' : '불참'} ({formatDate(new Date().toISOString(), 'KR')} 기준)
- </span>
- </div>
+ {/* 현재 상태 표시 */}
+ {isAttending !== null && (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="flex items-center gap-2 text-blue-800">
+ <Clock className="w-4 h-4" />
+ <span className="text-sm">
+ 현재 상태: {isAttending ? '참석' : '불참'} ({formatDate(new Date().toISOString(), 'KR')} 기준)
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ )}
</div>
)}
- </div>
+ </ScrollArea>
<DialogFooter className="flex gap-2">
<Button
@@ -240,10 +395,10 @@ export function PartnersBiddingAttendanceDialog({
</Button>
<Button
onClick={handleSubmit}
- disabled={isPending || !attendance}
+ disabled={isPending || !attendance || isLoading}
className="min-w-[100px]"
>
- {isPending ? '저장 중...' : '저장'}
+ {isPending ? '저장 중...' : '확인'}
</Button>
</DialogFooter>
</DialogContent>
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index 4c4db37f..c6ba4926 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -29,7 +29,6 @@ import {
submitPartnerResponse,
updatePartnerAttendance
} from '../detail/service'
-import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog'
import {
biddingStatusLabels,
contractTypeLabels,
@@ -75,20 +74,15 @@ interface BiddingDetail {
finalQuoteSubmittedAt: string
isWinner: boolean
isAttendingMeeting: boolean | null
- offeredPaymentTerms: string
- offeredTaxConditions: string
- offeredIncoterms: string
- offeredContractDeliveryDate: string
- offeredShippingPort: string
- offeredDestinationPort: string
- isPriceAdjustmentApplicable: boolean
- responsePaymentTerms: string
- responseTaxConditions: string
- responseIncoterms: string
+ // companyConditionResponses에서 가져온 조건들 (제시된 조건과 응답 모두)
+ paymentTermsResponse: string
+ taxConditionsResponse: string
+ incotermsResponse: string
proposedContractDeliveryDate: string
proposedShippingPort: string
proposedDestinationPort: string
priceAdjustmentResponse: boolean
+ sparePartResponse: string
additionalProposals: string
responseSubmittedAt: string
}
@@ -110,13 +104,11 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
proposedShippingPort: '',
proposedDestinationPort: '',
priceAdjustmentResponse: false,
+ sparePartResponse: '',
additionalProposals: '',
isAttendingMeeting: false,
})
- // 사양설명회 참석 여부 다이얼로그 상태
- const [isAttendanceDialogOpen, setIsAttendanceDialogOpen] = React.useState(false)
-
// 데이터 로드
React.useEffect(() => {
const loadData = async () => {
@@ -129,15 +121,16 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
// 기존 응답 데이터로 폼 초기화
setResponseData({
finalQuoteAmount: result.finalQuoteAmount?.toString() || '',
- paymentTermsResponse: result.responsePaymentTerms || '',
- taxConditionsResponse: result.responseTaxConditions || '',
- incotermsResponse: result.responseIncoterms || '',
+ paymentTermsResponse: result.paymentTermsResponse || '',
+ taxConditionsResponse: result.taxConditionsResponse || '',
+ incotermsResponse: result.incotermsResponse || '',
proposedContractDeliveryDate: result.proposedContractDeliveryDate || '',
proposedShippingPort: result.proposedShippingPort || '',
proposedDestinationPort: result.proposedDestinationPort || '',
priceAdjustmentResponse: result.priceAdjustmentResponse || false,
+ sparePartResponse: result.sparePartResponse || '',
additionalProposals: result.additionalProposals || '',
- isAttendingMeeting: false, // TODO: biddingCompanies에서 가져와야 함
+ isAttendingMeeting: result.isAttendingMeeting || false,
})
}
} catch (error) {
@@ -180,6 +173,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
proposedShippingPort: responseData.proposedShippingPort,
proposedDestinationPort: responseData.proposedDestinationPort,
priceAdjustmentResponse: responseData.priceAdjustmentResponse,
+ sparePartResponse: responseData.sparePartResponse,
additionalProposals: responseData.additionalProposals,
},
'current-user' // TODO: 실제 사용자 ID
@@ -191,15 +185,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
description: result.message,
})
- // 사양설명회 참석 여부도 업데이트
- if (responseData.isAttendingMeeting !== undefined) {
- await updatePartnerAttendance(
- biddingDetail.biddingCompanyId,
- responseData.isAttendingMeeting,
- 'current-user'
- )
- }
-
// 데이터 새로고침
const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId)
if (updatedDetail) {
@@ -272,26 +257,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
</div>
- {/* 사양설명회 참석 여부 버튼 */}
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- onClick={() => setIsAttendanceDialogOpen(true)}
- className="flex items-center gap-2"
- >
- <Users className="w-4 h-4" />
- 사양설명회 참석
- {biddingDetail.isAttendingMeeting !== null && (
- <div className="ml-1">
- {biddingDetail.isAttendingMeeting ? (
- <CheckCircle className="w-4 h-4 text-green-600" />
- ) : (
- <XCircle className="w-4 h-4 text-red-600" />
- )}
- </div>
- )}
- </Button>
- </div>
</div>
{/* 입찰 공고 섹션 */}
@@ -368,48 +333,68 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</CardContent>
</Card>
- {/* 제시된 조건 섹션 */}
+ {/* 현재 설정된 조건 섹션 */}
<Card>
<CardHeader>
- <CardTitle>제시된 입찰 조건</CardTitle>
+ <CardTitle>현재 설정된 입찰 조건</CardTitle>
</CardHeader>
<CardContent>
- <div className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium">지급조건</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
- {biddingDetail.offeredPaymentTerms ?
- JSON.parse(biddingDetail.offeredPaymentTerms).join(', ') :
- '정보 없음'}
+ {biddingDetail.paymentTermsResponse}
</div>
</div>
<div>
<Label className="text-sm font-medium">세금조건</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
- {biddingDetail.offeredTaxConditions ?
- JSON.parse(biddingDetail.offeredTaxConditions).join(', ') :
- '정보 없음'}
+ {biddingDetail.taxConditionsResponse}
</div>
</div>
<div>
<Label className="text-sm font-medium">운송조건</Label>
<div className="mt-1 p-3 bg-muted rounded-md">
- {biddingDetail.offeredIncoterms ?
- JSON.parse(biddingDetail.offeredIncoterms).join(', ') :
- '정보 없음'}
+ {biddingDetail.incotermsResponse}
</div>
</div>
- {biddingDetail.offeredContractDeliveryDate && (
- <div>
- <Label className="text-sm font-medium">계약납기일</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- {formatDate(biddingDetail.offeredContractDeliveryDate, 'KR')}
- </div>
+ <div>
+ <Label className="text-sm font-medium">제안 계약납기일</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.proposedContractDeliveryDate ? formatDate(biddingDetail.proposedContractDeliveryDate, 'KR') : '미설정'}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">제안 선적지</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.proposedShippingPort}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">제안 도착지</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.proposedDestinationPort}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">스페어파트 응답</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.sparePartResponse}
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">연동제 적용</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ {biddingDetail.priceAdjustmentResponse ? '적용' : '미적용'}
</div>
- )}
+ </div>
</div>
</CardContent>
</Card>
@@ -490,6 +475,28 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
</div>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label htmlFor="proposedDestinationPort">제안 도착지</Label>
+ <Input
+ id="proposedDestinationPort"
+ value={responseData.proposedDestinationPort}
+ onChange={(e) => setResponseData({...responseData, proposedDestinationPort: e.target.value})}
+ placeholder="도착지를 입력하세요"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="sparePartResponse">스페어파트 응답</Label>
+ <Input
+ id="sparePartResponse"
+ value={responseData.sparePartResponse}
+ onChange={(e) => setResponseData({...responseData, sparePartResponse: e.target.value})}
+ placeholder="스페어파트 관련 응답을 입력하세요"
+ />
+ </div>
+ </div>
+
<div className="space-y-2">
<Label htmlFor="additionalProposals">추가 제안사항</Label>
<Textarea
@@ -512,17 +519,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label>
</div>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="isAttendingMeeting"
- checked={responseData.isAttendingMeeting}
- onCheckedChange={(checked) =>
- setResponseData({...responseData, isAttendingMeeting: !!checked})
- }
- />
- <Label htmlFor="isAttendingMeeting">사양설명회에 참석합니다</Label>
- </div>
-
<div className="flex justify-end pt-4">
<Button onClick={handleSubmitResponse} disabled={isPending}>
<Send className="w-4 h-4 mr-2" />
@@ -531,32 +527,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</div>
</CardContent>
</Card>
-
- {/* 사양설명회 참석 여부 다이얼로그 */}
- <PartnersBiddingAttendanceDialog
- biddingDetail={{
- id: biddingDetail.id,
- biddingNumber: biddingDetail.biddingNumber,
- title: biddingDetail.title,
- preQuoteDate: biddingDetail.preQuoteDate,
- biddingRegistrationDate: biddingDetail.biddingRegistrationDate,
- evaluationDate: biddingDetail.evaluationDate,
- }}
- biddingCompanyId={biddingDetail.biddingCompanyId}
- isAttending={biddingDetail.isAttendingMeeting}
- open={isAttendanceDialogOpen}
- onOpenChange={setIsAttendanceDialogOpen}
- onSuccess={() => {
- // 데이터 새로고침
- const refreshData = async () => {
- const updatedDetail = await getBiddingDetailsForPartners(biddingId, companyId)
- if (updatedDetail) {
- setBiddingDetail(updatedDetail)
- }
- }
- refreshData()
- }}
- />
</div>
)
}
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index b54ca967..41cc329f 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -14,7 +14,7 @@ import {
CheckCircle,
XCircle,
Users,
- Eye,
+ FileText,
MoreHorizontal,
Calendar,
User
@@ -22,6 +22,7 @@ import {
import { formatDate } from '@/lib/utils'
import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
import { PartnersBiddingListItem } from '../detail/service'
+import { Checkbox } from '@/components/ui/checkbox'
const columnHelper = createColumnHelper<PartnersBiddingListItem>()
@@ -31,6 +32,29 @@ interface PartnersBiddingListColumnsProps {
export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingListColumnsProps = {}) {
return [
+ // select 버튼
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
// 입찰 No.
columnHelper.accessor('biddingNumber', {
header: '입찰 No.',
@@ -66,10 +90,10 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
},
}),
- // 상세 (액션 버튼)
+ // 액션 (드롭다운 메뉴)
columnHelper.display({
id: 'actions',
- header: '상세',
+ header: 'Actions',
cell: ({ row }) => {
const handleView = () => {
if (setRowAction) {
@@ -80,15 +104,42 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}
}
+ const handleAttendance = () => {
+ if (setRowAction) {
+ setRowAction({
+ type: 'attendance',
+ row: { original: row.original }
+ })
+ }
+ }
+
+ const canManageAttendance = row.original.invitationStatus === 'sent' ||
+ row.original.invitationStatus === 'accepted'
+
return (
- <Button
- variant="outline"
- size="sm"
- onClick={handleView}
- className="h-8 w-8 p-0"
- >
- <Eye className="h-4 w-4" />
- </Button>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="ghost"
+ className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
+ >
+ <MoreHorizontal className="h-4 w-4" />
+ <span className="sr-only">메뉴 열기</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-[160px]">
+ <DropdownMenuItem onClick={handleView}>
+ <FileText className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ {canManageAttendance && (
+ <DropdownMenuItem onClick={handleAttendance}>
+ <Users className="mr-2 h-4 w-4" />
+ 참석여부
+ </DropdownMenuItem>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
)
},
}),
@@ -247,14 +298,6 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
),
}),
- // 최종수정자
- columnHelper.accessor('updatedBy', {
- header: '최종수정자',
- cell: ({ row }) => (
- <div className="max-w-20 truncate" title={row.original.updatedBy || ''}>
- {row.original.updatedBy || '-'}
- </div>
- ),
- }),
+
]
}
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx
index c0356e22..aa185c3a 100644
--- a/lib/bidding/vendor/partners-bidding-list.tsx
+++ b/lib/bidding/vendor/partners-bidding-list.tsx
@@ -13,6 +13,8 @@ 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 { PartnersBiddingToolbarActions } from './partners-bidding-toolbar-actions'
+import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog'
interface PartnersBiddingListProps {
companyId: number
@@ -133,6 +135,19 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
clearOnDefault: true,
})
+ // 데이터 새로고침 함수
+ 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])
+
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
@@ -145,12 +160,32 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
}
return (
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <PartnersBiddingToolbarActions table={table} onRefresh={refreshData} setRowAction={setRowAction} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <PartnersBiddingAttendanceDialog
+ open={rowAction?.type === "attendance"}
+ onOpenChange={() => setRowAction(null)}
+ biddingDetail={rowAction?.row.original ? {
+ id: rowAction.row.original.biddingId,
+ biddingNumber: rowAction.row.original.biddingNumber,
+ title: rowAction.row.original.title,
+ preQuoteDate: null,
+ biddingRegistrationDate: rowAction.row.original.submissionStartDate?.toISOString() || null,
+ evaluationDate: null,
+ } : null}
+ biddingCompanyId={rowAction?.row.original?.biddingCompanyId || 0}
+ isAttending={rowAction?.row.original?.isAttendingMeeting || null}
+ onSuccess={refreshData}
/>
- </DataTable>
+ </>
)
}
diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
new file mode 100644
index 00000000..c45568bd
--- /dev/null
+++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Users } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { PartnersBiddingListItem } from '../detail/service'
+
+interface PartnersBiddingToolbarActionsProps {
+ table: Table<PartnersBiddingListItem>
+ onRefresh: () => void
+ setRowAction?: (action: { type: string; row: { original: PartnersBiddingListItem } }) => void
+}
+
+export function PartnersBiddingToolbarActions({
+ table,
+ onRefresh,
+ setRowAction
+}: PartnersBiddingToolbarActionsProps) {
+ // 선택된 행 가져오기 (단일 선택)
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedBidding = selectedRows.length === 1 ? selectedRows[0].original : null
+
+ // 사양설명회 참석 여부 버튼 활성화 조건
+ const canManageAttendance = selectedBidding && (
+ selectedBidding.invitationStatus === 'sent' ||
+ selectedBidding.invitationStatus === 'accepted' ||
+ selectedBidding.invitationStatus === 'submitted'
+ )
+
+ const handleAttendanceClick = () => {
+ if (selectedBidding && setRowAction) {
+ setRowAction({
+ type: 'attendance',
+ row: { original: selectedBidding }
+ })
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAttendanceClick}
+ disabled={!canManageAttendance}
+ className="flex items-center gap-2"
+ >
+ <Users className="w-4 h-4" />
+ 사양설명회 참석여부
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/mail/templates/specification-meeting-attendance.hbs b/lib/mail/templates/specification-meeting-attendance.hbs
new file mode 100644
index 00000000..951bf72b
--- /dev/null
+++ b/lib/mail/templates/specification-meeting-attendance.hbs
@@ -0,0 +1,92 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>사양설명회 참석 알림</title>
+</head>
+<body style="margin: 0; padding: 0; font-family: 'Malgun Gothic', Arial, sans-serif; background-color: #f5f5f5;">
+ <div style="max-width: 600px; margin: 20px auto; background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
+ <!-- Header -->
+ <div style="background-color: #1e40af; color: white; padding: 20px; border-radius: 8px 8px 0 0;">
+ <h1 style="margin: 0; font-size: 24px; font-weight: bold;">사양설명회 참석 알림</h1>
+ <p style="margin: 8px 0 0 0; opacity: 0.9; font-size: 14px;">Specification Meeting Attendance Notification</p>
+ </div>
+
+ <!-- Content -->
+ <div style="padding: 30px;">
+ <!-- 기본 정보 -->
+ <div style="margin-bottom: 25px;">
+ <h2 style="color: #1e40af; font-size: 18px; margin-bottom: 15px; border-bottom: 2px solid #e5e7eb; padding-bottom: 8px;">입찰 정보</h2>
+ <table style="width: 100%; border-collapse: collapse;">
+ <tr>
+ <td style="padding: 8px 0; font-weight: bold; color: #374151; width: 120px;">입찰번호:</td>
+ <td style="padding: 8px 0; color: #6b7280;">{{biddingNumber}}</td>
+ </tr>
+ <tr>
+ <td style="padding: 8px 0; font-weight: bold; color: #374151;">입찰명:</td>
+ <td style="padding: 8px 0; color: #6b7280;">{{title}}</td>
+ </tr>
+ <tr>
+ <td style="padding: 8px 0; font-weight: bold; color: #374151;">업체명:</td>
+ <td style="padding: 8px 0; color: #6b7280;">{{companyName}}</td>
+ </tr>
+ </table>
+ </div>
+
+ <!-- 참석 정보 -->
+ <div style="background-color: #ecfdf5; border: 1px solid #a7f3d0; border-radius: 6px; padding: 20px; margin-bottom: 25px;">
+ <h3 style="color: #065f46; margin: 0 0 15px 0; font-size: 16px; display: flex; align-items: center;">
+ <span style="background-color: #10b981; color: white; border-radius: 50%; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; font-size: 12px;">✓</span>
+ 참석 확정
+ </h3>
+ <table style="width: 100%; border-collapse: collapse;">
+ <tr>
+ <td style="padding: 5px 0; font-weight: bold; color: #065f46; width: 120px;">참석인원:</td>
+ <td style="padding: 5px 0; color: #047857;">{{attendeeCount}}명</td>
+ </tr>
+ <tr>
+ <td style="padding: 5px 0; font-weight: bold; color: #065f46;">참석자 대표:</td>
+ <td style="padding: 5px 0; color: #047857;">{{representativeName}}</td>
+ </tr>
+ <tr>
+ <td style="padding: 5px 0; font-weight: bold; color: #065f46;">연락처:</td>
+ <td style="padding: 5px 0; color: #047857;">{{representativePhone}}</td>
+ </tr>
+ </table>
+ </div>
+
+ <!-- 안내 사항 -->
+ <div style="background-color: #fef3c7; border: 1px solid #fbbf24; border-radius: 6px; padding: 15px; margin-bottom: 25px;">
+ <h3 style="color: #92400e; margin: 0 0 10px 0; font-size: 14px; font-weight: bold;">📋 안내사항</h3>
+ <ul style="margin: 0; padding-left: 20px; color: #92400e; font-size: 14px; line-height: 1.5;">
+ <li>사양설명회 일정 및 장소는 별도로 안내드릴 예정입니다.</li>
+ <li>참석인원 변경이 필요한 경우 사전에 연락바랍니다.</li>
+ <li>문의사항이 있으시면 아래 연락처로 연락해주세요.</li>
+ </ul>
+ </div>
+
+ <!-- 연락처 정보 -->
+ <div style="border-top: 1px solid #e5e7eb; padding-top: 20px;">
+ <h3 style="color: #374151; font-size: 16px; margin-bottom: 10px;">담당자 연락처</h3>
+ <p style="margin: 5px 0; color: #6b7280; font-size: 14px;">
+ 📧 이메일: {{managerEmail}}<br>
+ {{#if managerPhone}}📞 전화: {{managerPhone}}<br>{{/if}}
+ 👤 담당자: {{managerName}}
+ </p>
+ </div>
+ </div>
+
+ <!-- Footer -->
+ <div style="background-color: #f9fafb; padding: 20px; border-radius: 0 0 8px 8px; border-top: 1px solid #e5e7eb; text-align: center;">
+ <p style="margin: 0; color: #6b7280; font-size: 12px;">
+ 본 메일은 자동으로 발송되었습니다. 회신하지 마세요.<br>
+ This email was sent automatically. Please do not reply.
+ </p>
+ <p style="margin: 10px 0 0 0; color: #9ca3af; font-size: 11px;">
+ © {{currentYear}} Samsung Heavy Industries. All rights reserved.
+ </p>
+ </div>
+ </div>
+</body>
+</html>