diff options
Diffstat (limited to 'lib/bidding/detail')
4 files changed, 80 insertions, 400 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 39bf0c46..d0f8070f 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -1505,6 +1505,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part respondedAt: biddingCompanies.respondedAt, finalQuoteAmount: biddingCompanies.finalQuoteAmount, finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt, + isFinalSubmission: biddingCompanies.isFinalSubmission, isWinner: biddingCompanies.isWinner, isAttendingMeeting: biddingCompanies.isAttendingMeeting, isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, @@ -1624,6 +1625,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId: isPreQuoteSelected: biddingCompanies.isPreQuoteSelected, isBiddingParticipated: biddingCompanies.isBiddingParticipated, isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated, + isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion, hasSpecificationMeeting: biddings.hasSpecificationMeeting, // 응답한 조건들 (company_condition_responses) - 제시된 조건과 응답 모두 여기서 관리 paymentTermsResponse: companyConditionResponses.paymentTermsResponse, @@ -1811,37 +1813,37 @@ export async function submitPartnerResponse( // 임시저장: invitationStatus는 변경하지 않음 (bidding_accepted 유지) } - // 스냅샷은 임시저장/최종제출 관계없이 항상 생성 - if (response.prItemQuotations && response.prItemQuotations.length > 0) { - // 기존 스냅샷 조회 - const existingCompany = await tx - .select({ quotationSnapshots: biddingCompanies.quotationSnapshots }) - .from(biddingCompanies) - .where(eq(biddingCompanies.id, biddingCompanyId)) - .limit(1) - - const existingSnapshots = existingCompany[0]?.quotationSnapshots as any[] || [] - - // 새로운 스냅샷 생성 - const newSnapshot = { - id: Date.now().toString(), // 고유 ID - round: existingSnapshots.length + 1, // 차수 - submittedAt: new Date().toISOString(), - totalAmount: response.finalQuoteAmount, - currency: 'KRW', - isFinalSubmission: !!response.isFinalSubmission, - items: response.prItemQuotations.map(item => ({ - prItemId: item.prItemId, - bidUnitPrice: item.bidUnitPrice, - bidAmount: item.bidAmount, - proposedDeliveryDate: item.proposedDeliveryDate, - technicalSpecification: item.technicalSpecification - })) - } - - // 스냅샷 배열에 추가 - companyUpdateData.quotationSnapshots = [...existingSnapshots, newSnapshot] - } + // // 스냅샷은 임시저장/최종제출 관계없이 항상 생성 + // if (response.prItemQuotations && response.prItemQuotations.length > 0) { + // // 기존 스냅샷 조회 + // const existingCompany = await tx + // .select({ quotationSnapshots: biddingCompanies.quotationSnapshots }) + // .from(biddingCompanies) + // .where(eq(biddingCompanies.id, biddingCompanyId)) + // .limit(1) + + // const existingSnapshots = existingCompany[0]?.quotationSnapshots as any[] || [] + + // // 새로운 스냅샷 생성 + // const newSnapshot = { + // id: Date.now().toString(), // 고유 ID + // round: existingSnapshots.length + 1, // 차수 + // submittedAt: new Date().toISOString(), + // totalAmount: response.finalQuoteAmount, + // currency: 'KRW', + // isFinalSubmission: !!response.isFinalSubmission, + // items: response.prItemQuotations.map(item => ({ + // prItemId: item.prItemId, + // bidUnitPrice: item.bidUnitPrice, + // bidAmount: item.bidAmount, + // proposedDeliveryDate: item.proposedDeliveryDate, + // technicalSpecification: item.technicalSpecification + // })) + // } + + // // 스냅샷 배열에 추가 + // companyUpdateData.quotationSnapshots = [...existingSnapshots, newSnapshot] + // } } await tx @@ -2342,47 +2344,40 @@ export async function deleteBiddingDocument(documentId: number, biddingId: numbe } } -// 협력업체용 발주처 문서 조회 (캐시 적용) +// 협력업체용 발주처 문서 조회 (협력업체용 첨부파일만) export async function getBiddingDocumentsForPartners(biddingId: number) { - return unstable_cache( - async () => { - try { - const documents = await db - .select({ - id: biddingDocuments.id, - biddingId: biddingDocuments.biddingId, - companyId: biddingDocuments.companyId, - documentType: biddingDocuments.documentType, - fileName: biddingDocuments.fileName, - originalFileName: biddingDocuments.originalFileName, - fileSize: biddingDocuments.fileSize, - filePath: biddingDocuments.filePath, - title: biddingDocuments.title, - description: biddingDocuments.description, - uploadedAt: biddingDocuments.uploadedAt, - uploadedBy: biddingDocuments.uploadedBy - }) - .from(biddingDocuments) - .where( - and( - eq(biddingDocuments.biddingId, biddingId), - sql`${biddingDocuments.companyId} IS NULL`, // 발주처 문서만 - eq(biddingDocuments.isPublic, true) // 공개 문서만 - ) - ) - .orderBy(desc(biddingDocuments.uploadedAt)) + try { + const documents = await db + .select({ + id: biddingDocuments.id, + biddingId: biddingDocuments.biddingId, + companyId: biddingDocuments.companyId, + documentType: biddingDocuments.documentType, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + description: biddingDocuments.description, + uploadedAt: biddingDocuments.uploadedAt, + uploadedBy: biddingDocuments.uploadedBy + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'company_proposal'), // 협력업체용 첨부파일만 + sql`${biddingDocuments.companyId} IS NULL`, // 발주처 문서만 + eq(biddingDocuments.isPublic, true) // 공개 문서만 + ) + ) + .orderBy(desc(biddingDocuments.uploadedAt)) - return documents - } catch (error) { - console.error('Failed to get bidding documents for partners:', error) - return [] - } - }, - [`bidding-documents-partners-${biddingId}`], - { - tags: [`bidding-${biddingId}`, 'bidding-documents'] - } - )() + return documents + } catch (error) { + console.error('Failed to get bidding documents for partners:', error) + return [] + } } // ================================================= 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 6e5481f4..5bc85fdb 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-edit-dialog.tsx @@ -22,7 +22,7 @@ interface BiddingDetailVendorEditDialogProps { open: boolean onOpenChange: (open: boolean) => void onSuccess: () => void - biddingAwardCount?: string // 낙찰수 정보 추가 + biddingAwardCount?: string // 낙찰업체 수 정보 추가 biddingStatus?: string // 입찰 상태 정보 추가 allVendors?: QuotationVendor[] // 전체 벤더 목록 추가 } @@ -55,7 +55,7 @@ export function BiddingDetailVendorEditDialog({ // vendor가 변경되면 폼 데이터 업데이트 React.useEffect(() => { if (vendor) { - // 낙찰수가 단수인 경우 발주비율을 100%로 자동 설정 + // 낙찰업체 수가 단수인 경우 발주비율을 100%로 자동 설정 const defaultAwardRatio = biddingAwardCount === 'single' ? 100 : (vendor.awardRatio || 0) setFormData({ awardRatio: defaultAwardRatio, diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index 34ee690f..53fe05f9 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -286,14 +286,14 @@ export function BiddingDetailVendorToolbarActions({ bidding.status === 'bidding_disposal') && ( <div className="h-4 w-px bg-border mx-1" /> )} - <Button + {/* <Button variant="outline" size="sm" onClick={handleDocumentUpload} > <FileText className="mr-2 h-4 w-4" /> 입찰문서 등록 - </Button> + </Button> */} </div> diff --git a/lib/bidding/detail/table/bidding-invitation-dialog.tsx b/lib/bidding/detail/table/bidding-invitation-dialog.tsx index ffb1fcb3..582622d9 100644 --- a/lib/bidding/detail/table/bidding-invitation-dialog.tsx +++ b/lib/bidding/detail/table/bidding-invitation-dialog.tsx @@ -33,7 +33,6 @@ import { } from 'lucide-react' import { getExistingBasicContractsForBidding } from '../../pre-quote/service' import { getActiveContractTemplates } from '../../service' -import { getVendorContacts } from '@/lib/vendors/service' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' import { SelectTrigger } from '@/components/ui/select' @@ -269,47 +268,23 @@ export function BiddingInvitationDialog({ })); setSelectedContracts(initialSelected); - // 벤더 담당자 정보 병렬로 가져오기 - const vendorContactsPromises = selectedVendors.map(vendor => - getVendorContacts({ - page: 1, - perPage: 100, - flags: [], - sort: [], - filters: [], - joinOperator: 'and', - search: '', - contactName: '', - contactPosition: '', - contactEmail: '', - contactPhone: '' - }, vendor.vendorId) - .then(result => ({ - vendorId: vendor.vendorId, - contacts: (result.data || []).map(contact => ({ - id: contact.id, - contactName: contact.contactName, - contactEmail: contact.contactEmail, - contactPhone: contact.contactPhone, - contactPosition: contact.contactPosition, - contactDepartment: contact.contactDepartment - })) - })) - .catch(() => ({ - vendorId: vendor.vendorId, - contacts: [] - })) - ); - - const vendorContactsResults = await Promise.all(vendorContactsPromises); - const vendorContactsMap = new Map(vendorContactsResults.map(result => [result.vendorId, result.contacts])); + // 담당자 정보는 selectedVendors에 이미 포함되어 있음 // vendorData 초기화 (담당자 정보 포함) const initialVendorData: VendorWithContactInfo[] = selectedVendors.map(vendor => { const hasExistingContract = typedContracts.some((ec) => ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId ); - const vendorContacts = vendorContactsMap.get(vendor.vendorId) || []; + + // contacts 정보가 이미 selectedVendors에 포함되어 있음 + const vendorContacts = (vendor.contacts || []).map(contact => ({ + id: contact.id, + contactName: contact.contactName, + contactEmail: contact.contactEmail, + contactPhone: contact.contactNumber, + contactPosition: null, + contactDepartment: null + })); // 주 수신자 기본값: 벤더의 기본 이메일 (vendorEmail) const defaultEmail = vendor.vendorEmail || (vendorContacts.length > 0 ? vendorContacts[0].contactEmail : ''); @@ -569,296 +544,6 @@ export function BiddingInvitationDialog({ )} {/* 대상 업체 정보 - 테이블 형식 */} - <div className="space-y-4"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2 text-sm font-medium"> - <Building2 className="h-4 w-4" /> - 초대 대상 업체 ({vendorData.length}) - </div> - <Badge variant="outline" className="flex items-center gap-1"> - <Users className="h-3 w-3" /> - 총 {totalRecipientCount}명 - </Badge> - </div> - - {vendorData.length === 0 ? ( - <div className="text-center py-6 text-muted-foreground border rounded-lg"> - 초대 가능한 업체가 없습니다. - </div> - ) : ( - <div className="border rounded-lg overflow-hidden"> - <table className="w-full"> - <thead className="bg-muted/50 border-b"> - <tr> - <th className="text-left p-2 text-xs font-medium">No.</th> - <th className="text-left p-2 text-xs font-medium">업체명</th> - <th className="text-left p-2 text-xs font-medium">주 수신자</th> - <th className="text-left p-2 text-xs font-medium">CC</th> - <th className="text-left p-2 text-xs font-medium">작업</th> - </tr> - </thead> - <tbody> - {vendorData.map((vendor, index) => { - const allContacts = vendor.contacts || []; - const allEmails = [ - // 벤더의 기본 이메일을 첫 번째로 표시 - ...(vendor.vendorEmail ? [{ - value: vendor.vendorEmail, - label: `${vendor.vendorEmail}`, - email: vendor.vendorEmail, - type: 'vendor' as const - }] : []), - // 담당자 이메일들 - ...allContacts.map(c => ({ - value: c.contactEmail, - label: `${c.contactName} ${c.contactPosition ? `(${c.contactPosition})` : ''}`, - email: c.contactEmail, - type: 'contact' as const - })), - // 커스텀 이메일들 - ...vendor.customEmails.map(c => ({ - value: c.email, - label: c.name || c.email, - email: c.email, - type: 'custom' as const - })) - ]; - - const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail); - const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail); - const isFormOpen = showCustomEmailForm[vendor.vendorId]; - - return ( - <React.Fragment key={vendor.vendorId}> - <tr className="border-b hover:bg-muted/20"> - <td className="p-2"> - <div className="flex items-center gap-1"> - <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium"> - {index + 1} - </div> - </div> - </td> - <td className="p-2"> - <div className="space-y-1"> - <div className="font-medium text-sm">{vendor.vendorName}</div> - <div className="flex items-center gap-1"> - <Badge variant="outline" className="text-xs"> - {vendor.vendorCountry || vendor.vendorCode} - </Badge> - </div> - </div> - </td> - <td className="p-2"> - <Select - value={vendor.selectedMainEmail} - onValueChange={(value) => updateVendor(vendor.vendorId, { selectedMainEmail: value })} - > - <SelectTrigger className="h-7 text-xs w-[200px]"> - <SelectValue placeholder="선택하세요"> - {selectedMainEmailInfo && ( - <div className="flex items-center gap-1"> - {selectedMainEmailInfo.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />} - <span className="truncate">{selectedMainEmailInfo.label}</span> - </div> - )} - </SelectValue> - </SelectTrigger> - <SelectContent> - {allEmails.map((email) => ( - <SelectItem key={email.value} value={email.value} className="text-xs"> - <div className="flex items-center gap-1"> - {email.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />} - <span>{email.label}</span> - </div> - </SelectItem> - ))} - </SelectContent> - </Select> - {!vendor.selectedMainEmail && ( - <span className="text-xs text-red-500">필수</span> - )} - </td> - <td className="p-2"> - <Popover> - <PopoverTrigger asChild> - <Button variant="outline" className="h-7 text-xs"> - {vendor.additionalEmails.length > 0 - ? `${vendor.additionalEmails.length}명` - : "선택" - } - <ChevronDown className="ml-1 h-3 w-3" /> - </Button> - </PopoverTrigger> - <PopoverContent className="w-48 p-2"> - <div className="max-h-48 overflow-y-auto space-y-1"> - {ccEmails.map((email) => ( - <div key={email.value} className="flex items-center space-x-1 p-1"> - <Checkbox - checked={vendor.additionalEmails.includes(email.value)} - onCheckedChange={() => toggleAdditionalEmail(vendor.vendorId, email.value)} - className="h-3 w-3" - /> - <label className="text-xs cursor-pointer flex-1 truncate"> - {email.label} - </label> - </div> - ))} - </div> - </PopoverContent> - </Popover> - </td> - <td className="p-2"> - <div className="flex items-center gap-1"> - <Button - variant={isFormOpen ? "default" : "ghost"} - size="sm" - className="h-6 w-6 p-0" - onClick={() => { - setShowCustomEmailForm(prev => ({ - ...prev, - [vendor.vendorId]: !prev[vendor.vendorId] - })); - }} - > - {isFormOpen ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />} - </Button> - {vendor.customEmails.length > 0 && ( - <Badge variant="secondary" className="text-xs"> - +{vendor.customEmails.length} - </Badge> - )} - </div> - </td> - </tr> - - {/* 인라인 수신자 추가 폼 */} - {isFormOpen && ( - <tr className="bg-muted/10 border-b"> - <td colSpan={5} className="p-4"> - <div className="space-y-3"> - <div className="flex items-center justify-between mb-2"> - <div className="flex items-center gap-2 text-sm font-medium"> - <UserPlus className="h-4 w-4" /> - 수신자 추가 - {vendor.vendorName} - </div> - <Button - variant="ghost" - size="sm" - className="h-6 w-6 p-0" - onClick={() => setShowCustomEmailForm(prev => ({ - ...prev, - [vendor.vendorId]: false - }))} - > - <X className="h-3 w-3" /> - </Button> - </div> - - <div className="flex gap-2 items-end"> - <div className="w-[150px]"> - <Label className="text-xs mb-1 block">이름 (선택)</Label> - <Input - placeholder="홍길동" - className="h-8 text-sm" - value={customEmailInputs[vendor.vendorId]?.name || ''} - onChange={(e) => setCustomEmailInputs(prev => ({ - ...prev, - [vendor.vendorId]: { - ...prev[vendor.vendorId], - name: e.target.value - } - }))} - /> - </div> - <div className="flex-1"> - <Label className="text-xs mb-1 block">이메일 <span className="text-red-500">*</span></Label> - <Input - type="email" - placeholder="example@company.com" - className="h-8 text-sm" - value={customEmailInputs[vendor.vendorId]?.email || ''} - onChange={(e) => setCustomEmailInputs(prev => ({ - ...prev, - [vendor.vendorId]: { - ...prev[vendor.vendorId], - email: e.target.value - } - }))} - onKeyPress={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - addCustomEmail(vendor.vendorId); - } - }} - /> - </div> - <Button - size="sm" - className="h-8 px-4" - onClick={() => addCustomEmail(vendor.vendorId)} - disabled={!customEmailInputs[vendor.vendorId]?.email} - > - <Plus className="h-3 w-3 mr-1" /> - 추가 - </Button> - <Button - variant="outline" - size="sm" - className="h-8 px-4" - onClick={() => { - setCustomEmailInputs(prev => ({ - ...prev, - [vendor.vendorId]: { email: '', name: '' } - })); - setShowCustomEmailForm(prev => ({ - ...prev, - [vendor.vendorId]: false - })); - }} - > - 취소 - </Button> - </div> - - {/* 추가된 커스텀 이메일 목록 */} - {vendor.customEmails.length > 0 && ( - <div className="mt-3 pt-3 border-t"> - <div className="text-xs text-muted-foreground mb-2">추가된 수신자 목록</div> - <div className="grid grid-cols-2 xl:grid-cols-3 gap-2"> - {vendor.customEmails.map((custom) => ( - <div key={custom.id} className="flex items-center justify-between bg-background rounded-md p-2"> - <div className="flex items-center gap-2 min-w-0"> - <UserPlus className="h-3 w-3 text-green-500 flex-shrink-0" /> - <div className="min-w-0"> - <div className="text-sm font-medium truncate">{custom.name}</div> - <div className="text-xs text-muted-foreground truncate">{custom.email}</div> - </div> - </div> - <Button - variant="ghost" - size="sm" - className="h-6 w-6 p-0 flex-shrink-0" - onClick={() => removeCustomEmail(vendor.vendorId, custom.id)} - > - <X className="h-3 w-3" /> - </Button> - </div> - ))} - </div> - </div> - )} - </div> - </td> - </tr> - )} - </React.Fragment> - ); - })} - </tbody> - </table> - </div> - )} - </div> <Separator /> |
