diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/bidding/detail/service.ts | 326 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-columns.tsx | 101 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx | 155 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx | 153 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-attendance-dialog.tsx | 381 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-detail.tsx | 176 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list-columns.tsx | 83 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list.tsx | 47 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-toolbar-actions.tsx | 55 | ||||
| -rw-r--r-- | lib/mail/templates/specification-meeting-attendance.hbs | 92 |
10 files changed, 1113 insertions, 456 deletions
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> |
