diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-20 10:25:41 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-20 10:25:41 +0000 |
| commit | b75b1cd920efd61923f7b2dbc4c49987b7b0c4e1 (patch) | |
| tree | 9e4195e697df6df21b5896b0d33acc97d698b4a7 /lib | |
| parent | 4df8d72b79140919c14df103b45bbc8b1afa37c2 (diff) | |
(최겸) 구매 입찰 수정
Diffstat (limited to 'lib')
19 files changed, 425 insertions, 639 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 /> diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 40c7f271..36abd03c 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -122,14 +122,14 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef // ░░░ 프로젝트명 ░░░ { accessorKey: "projectName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />, + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 No." />, cell: ({ row }) => ( <div className="truncate max-w-[150px]" title={row.original.projectName || ''}> {row.original.projectName || '-'} </div> ), size: 150, - meta: { excelHeader: "프로젝트명" }, + meta: { excelHeader: "프로젝트 No." }, }, // ░░░ 입찰명 ░░░ { diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx index 0cb87b11..3f65f559 100644 --- a/lib/bidding/list/biddings-table-toolbar-actions.tsx +++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx @@ -80,26 +80,12 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio .getFilteredSelectedRowModel() .rows .map(row => row.original) - }, [table]) + }, [table.getFilteredSelectedRowModel().rows]) // 업체선정이 완료된 입찰만 전송 가능 - const canTransmit = selectedBiddings.length === 1 && selectedBiddings[0].status === 'vendor_selected' - - const handleExport = async () => { - try { - setIsExporting(true) - await exportTableToExcel(table, { - filename: "biddings", - excludeColumns: ["select", "actions"], - }) - toast.success("입찰 목록이 성공적으로 내보내졌습니다.") - } catch { - toast.error("내보내기 중 오류가 발생했습니다.") - } finally { - setIsExporting(false) - } - } - + const canTransmit = true + console.log(canTransmit, 'canTransmit') + console.log(selectedBiddings, 'selectedBiddings') return ( <> @@ -121,41 +107,6 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio <Send className="size-4" aria-hidden="true" /> <span className="hidden sm:inline">전송하기</span> </Button> - - {/* 개찰 (입찰 오픈) */} - {/* {openEligibleBiddings.length > 0 && ( - <Button - variant="outline" - size="sm" - onClick={handleBiddingOpen} - > - <Gavel className="mr-2 h-4 w-4" /> - 개찰 ({openEligibleBiddings.length}) - </Button> - )} */} - - {/* Export */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="gap-2" - disabled={isExporting} - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline"> - {isExporting ? "내보내는 중..." : "Export"} - </span> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={handleExport} disabled={isExporting}> - <FileSpreadsheet className="mr-2 size-4" /> - <span>입찰 목록 내보내기</span> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> </div> {/* 전송 다이얼로그 */} diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx index 89b6260c..35d57726 100644 --- a/lib/bidding/list/biddings-table.tsx +++ b/lib/bidding/list/biddings-table.tsx @@ -128,6 +128,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { filterFields, enablePinning: true, enableAdvancedFilter: true, + enableMultiRowSelection: false, initialState: { sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, diff --git a/lib/bidding/list/biddings-transmission-dialog.tsx b/lib/bidding/list/biddings-transmission-dialog.tsx index de28bf54..7eb7ffd1 100644 --- a/lib/bidding/list/biddings-transmission-dialog.tsx +++ b/lib/bidding/list/biddings-transmission-dialog.tsx @@ -92,6 +92,40 @@ export function TransmissionDialog({ open, onOpenChange, bidding, userId }: Tran if (!bidding) return null
+ // 업체선정이 완료되지 않은 경우 에러 표시
+ if (bidding.status !== 'vendor_selected') {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[400px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2 text-red-600">
+ <Send className="w-5 h-5" />
+ 전송 불가
+ </DialogTitle>
+ <DialogDescription>
+ 업체선정이 완료된 입찰만 전송할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="py-4">
+ <div className="text-center">
+ <p className="text-sm text-muted-foreground">
+ 현재 상태: <span className="font-medium">{bidding.status}</span>
+ </p>
+ <p className="text-xs text-muted-foreground mt-2">
+ 업체선정이 완료된 후 다시 시도해주세요.
+ </p>
+ </div>
+ </div>
+ <DialogFooter>
+ <Button onClick={() => onOpenChange(false)}>
+ 확인
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
const handleToContract = async () => {
try {
setIsLoading(true)
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index ff68e739..2f458873 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -1438,11 +1438,11 @@ export function CreateBiddingDialog() { name="awardCount" render={({ field }) => ( <FormItem> - <FormLabel>낙찰수 <span className="text-red-500">*</span></FormLabel> + <FormLabel>낙찰업체 수 <span className="text-red-500">*</span></FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> - <SelectValue placeholder="낙찰수 선택" /> + <SelectValue placeholder="낙찰업체 수 선택" /> </SelectTrigger> </FormControl> <SelectContent> diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index 19b418ae..81daf506 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -1,7 +1,7 @@ 'use server'
import db from '@/db/db'
-import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
+import { biddingCompanies, biddingCompaniesContacts, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
import { basicContractTemplates } from '@/db/schema'
import { vendors } from '@/db/schema/vendors'
import { users } from '@/db/schema'
@@ -1565,10 +1565,11 @@ export async function getExistingBasicContractsForBidding(biddingId: number) { }
}
-// 선정된 업체들 조회 (서버 액션)
+// 입찰 참여 업체들 조회 (벤더와 담당자 정보 포함)
export async function getSelectedVendorsForBidding(biddingId: number) {
try {
- const selectedCompanies = await db
+ // 1. 입찰에 참여하는 모든 업체 조회
+ const companies = await db
.select({
id: biddingCompanies.id,
companyId: biddingCompanies.companyId,
@@ -1579,37 +1580,66 @@ export async function getSelectedVendorsForBidding(biddingId: number) { contactPerson: biddingCompanies.contactPerson,
contactEmail: biddingCompanies.contactEmail,
biddingId: biddingCompanies.biddingId,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true)
- ))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ // 2. 각 업체의 담당자 정보 조회
+ const vendorsWithContacts = await Promise.all(
+ companies.map(async (company) => {
+ let contacts: any[] = []
+
+ if (company.companyId) {
+ // biddingCompaniesContacts에서 담당자 조회
+ const contactsResult = await db
+ .select({
+ id: biddingCompaniesContacts.id,
+ contactName: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactNumber: biddingCompaniesContacts.contactNumber,
+ })
+ .from(biddingCompaniesContacts)
+ .where(
+ and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, company.companyId)
+ )
+ )
+
+ contacts = contactsResult
+ }
+
+ return {
+ vendorId: company.companyId,
+ vendorName: company.companyName || '',
+ vendorCode: company.companyCode,
+ vendorEmail: company.companyEmail,
+ vendorCountry: company.companyCountry || '대한민국',
+ contactPerson: company.contactPerson,
+ contactEmail: company.contactEmail,
+ biddingCompanyId: company.id,
+ biddingId: company.biddingId,
+ isPreQuoteSelected: company.isPreQuoteSelected,
+ ndaYn: true,
+ generalGtcYn: true,
+ projectGtcYn: true,
+ agreementYn: true,
+ contacts: contacts // 담당자 목록 추가
+ }
+ })
+ )
return {
success: true,
- vendors: selectedCompanies.map(company => ({
- vendorId: company.companyId, // 실제 vendor ID
- vendorName: company.companyName || '',
- vendorCode: company.companyCode,
- vendorEmail: company.companyEmail,
- vendorCountry: company.companyCountry || '대한민국',
- contactPerson: company.contactPerson,
- contactEmail: company.contactEmail,
- biddingCompanyId: company.id, // biddingCompany ID
- biddingId: company.biddingId,
- ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
- generalGtcYn: true,
- projectGtcYn: true,
- agreementYn: true
- }))
+ vendors: vendorsWithContacts
}
} catch (error) {
- console.error('선정된 업체 조회 실패:', error)
+ console.error('입찰 참여 업체 조회 실패:', error)
return {
success: false,
- error: '선정된 업체 조회에 실패했습니다.',
+ error: '입찰 참여 업체 조회에 실패했습니다.',
vendors: []
}
}
diff --git a/lib/bidding/selection/actions.ts b/lib/bidding/selection/actions.ts index 16b2c083..0d2a8a75 100644 --- a/lib/bidding/selection/actions.ts +++ b/lib/bidding/selection/actions.ts @@ -115,20 +115,17 @@ export async function saveSelectionResult(data: SaveSelectionResultData) { // 견적 히스토리 조회 export async function getQuotationHistory(biddingId: number, vendorId: number) { try { - // biddingCompanies에서 해당 벤더의 스냅샷 데이터 조회 - const companyData = await db + // 현재 bidding의 biddingNumber와 originalBiddingNumber 조회 + const currentBiddingInfo = await db .select({ - quotationSnapshots: biddingCompanies.quotationSnapshots + biddingNumber: biddings.biddingNumber, + originalBiddingNumber: biddings.originalBiddingNumber }) - .from(biddingCompanies) - .where(and( - eq(biddingCompanies.biddingId, biddingId), - eq(biddingCompanies.companyId, vendorId) - )) + .from(biddings) + .where(eq(biddings.id, biddingId)) .limit(1) - // 데이터 존재 여부 및 유효성 체크 - if (!companyData.length || !companyData[0]?.quotationSnapshots) { + if (!currentBiddingInfo.length) { return { success: true, data: { @@ -137,40 +134,62 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) { } } - let snapshots = companyData[0].quotationSnapshots + const baseNumber = currentBiddingInfo[0].originalBiddingNumber || currentBiddingInfo[0].biddingNumber.split('-')[0] - // quotationSnapshots가 JSONB 타입이므로 파싱이 필요할 수 있음 - if (typeof snapshots === 'string') { - try { - snapshots = JSON.parse(snapshots) - } catch (parseError) { - console.error('Failed to parse quotationSnapshots:', parseError) - return { - success: true, - data: { - history: [] - } - } + // 동일한 originalBiddingNumber를 가진 모든 bidding 조회 + const relatedBiddings = await db + .select({ + id: biddings.id, + biddingNumber: biddings.biddingNumber, + targetPrice: biddings.targetPrice, + currency: biddings.currency, + createdAt: biddings.createdAt + }) + .from(biddings) + .where(eq(biddings.originalBiddingNumber, baseNumber)) + .orderBy(biddings.createdAt) + + // 각 bidding에 대한 벤더의 견적 정보 조회 + const historyPromises = relatedBiddings.map(async (bidding) => { + const biddingCompanyData = await db + .select({ + finalQuoteAmount: biddingCompanies.finalQuoteAmount, + responseSubmittedAt: biddingCompanies.responseSubmittedAt, + isFinalSubmission: biddingCompanies.isFinalSubmission + }) + .from(biddingCompanies) + .where(and( + eq(biddingCompanies.biddingId, bidding.id), + eq(biddingCompanies.companyId, vendorId) + )) + .limit(1) + + if (!biddingCompanyData.length || !biddingCompanyData[0].finalQuoteAmount || !biddingCompanyData[0].responseSubmittedAt) { + return null } - } - // snapshots가 배열인지 확인 - if (!Array.isArray(snapshots)) { - console.error('quotationSnapshots is not an array:', typeof snapshots) return { - success: true, - data: { - history: [] - } + biddingId: bidding.id, + biddingNumber: bidding.biddingNumber, + finalQuoteAmount: biddingCompanyData[0].finalQuoteAmount, + responseSubmittedAt: biddingCompanyData[0].responseSubmittedAt, + isFinalSubmission: biddingCompanyData[0].isFinalSubmission, + targetPrice: bidding.targetPrice, + currency: bidding.currency } - } + }) - // PR 항목 정보 조회 (스냅샷의 prItemId로 매핑하기 위해) - const prItemIds = snapshots.flatMap(snapshot => - snapshot.items?.map((item: any) => item.prItemId) || [] - ).filter((id: number, index: number, arr: number[]) => arr.indexOf(id) === index) + const historyData = (await Promise.all(historyPromises)).filter(Boolean) + + // biddingNumber의 suffix를 기준으로 정렬 (-01, -02, -03 등) + const sortedHistory = historyData.sort((a, b) => { + const aSuffix = a!.biddingNumber.split('-')[1] ? parseInt(a!.biddingNumber.split('-')[1]) : 0 + const bSuffix = b!.biddingNumber.split('-')[1] ? parseInt(b!.biddingNumber.split('-')[1]) : 0 + return aSuffix - bSuffix + }) - const prItems = prItemIds.length > 0 ? await db + // PR 항목 정보 조회 (현재 bidding 기준) + const prItems = await db .select({ id: prItemsForBidding.id, itemNumber: prItemsForBidding.itemNumber, @@ -180,53 +199,54 @@ export async function getQuotationHistory(biddingId: number, vendorId: number) { requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate }) .from(prItemsForBidding) - .where(sql`${prItemsForBidding.id} IN ${prItemIds}`) : [] + .where(eq(prItemsForBidding.biddingId, biddingId)) - // PR 항목을 Map으로 변환하여 빠른 조회를 위해 - const prItemMap = new Map(prItems.map(item => [item.id, item])) - - // bidding 정보 조회 (targetPrice, currency) - const biddingInfo = await db - .select({ - targetPrice: biddings.targetPrice, - currency: biddings.currency - }) - .from(biddings) - .where(eq(biddings.id, biddingId)) - .limit(1) + // 각 히스토리 항목에 대한 PR 아이템 견적 조회 + const history = await Promise.all(sortedHistory.map(async (item, index) => { + // 각 bidding에 대한 PR 아이템 견적 조회 + const prItemBids = await db + .select({ + prItemId: companyPrItemBids.prItemId, + bidUnitPrice: companyPrItemBids.bidUnitPrice, + bidAmount: companyPrItemBids.bidAmount, + proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate + }) + .from(companyPrItemBids) + .where(and( + eq(companyPrItemBids.biddingId, item!.biddingId), + eq(companyPrItemBids.companyId, vendorId) + )) - const targetPrice = biddingInfo[0]?.targetPrice ? parseFloat(biddingInfo[0].targetPrice.toString()) : null - const currency = biddingInfo[0]?.currency || 'KRW' + const targetPrice = item!.targetPrice ? parseFloat(item!.targetPrice.toString()) : null + const totalAmount = parseFloat(item!.finalQuoteAmount.toString()) - // 스냅샷 데이터를 변환 - const history = snapshots.map((snapshot: any) => { const vsTargetPrice = targetPrice && targetPrice > 0 - ? ((snapshot.totalAmount - targetPrice) / targetPrice) * 100 + ? ((totalAmount - targetPrice) / targetPrice) * 100 : 0 - const items = snapshot.items?.map((item: any) => { - const prItem = prItemMap.get(item.prItemId) + const items = prItemBids.map(bid => { + const prItem = prItems.find(p => p.id === bid.prItemId) return { - itemCode: prItem?.itemNumber || `ITEM${item.prItemId}`, + itemCode: prItem?.itemNumber || `ITEM${bid.prItemId}`, itemName: prItem?.itemInfo || '품목 정보 없음', quantity: prItem?.quantity || 0, unit: prItem?.quantityUnit || 'EA', - unitPrice: item.bidUnitPrice, - totalPrice: item.bidAmount, - deliveryDate: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date() + unitPrice: parseFloat(bid.bidUnitPrice.toString()), + totalPrice: parseFloat(bid.bidAmount.toString()), + deliveryDate: bid.proposedDeliveryDate ? new Date(bid.proposedDeliveryDate) : prItem?.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : new Date() } - }) || [] + }) return { - id: snapshot.id, - round: snapshot.round, - submittedAt: new Date(snapshot.submittedAt), - totalAmount: snapshot.totalAmount, - currency: snapshot.currency || currency, + id: item!.biddingId, + round: index + 1, // 1차, 2차, 3차... + submittedAt: new Date(item!.responseSubmittedAt), + totalAmount, + currency: item!.currency || 'KRW', vsTargetPrice: parseFloat(vsTargetPrice.toFixed(2)), items } - }) + })) return { success: true, diff --git a/lib/bidding/selection/bidding-info-card.tsx b/lib/bidding/selection/bidding-info-card.tsx index f6f0bc69..8864e7db 100644 --- a/lib/bidding/selection/bidding-info-card.tsx +++ b/lib/bidding/selection/bidding-info-card.tsx @@ -65,9 +65,9 @@ export function BiddingInfoCard({ bidding }: BiddingInfoCardProps) { <label className="text-sm font-medium text-muted-foreground"> 진행상태 </label> - <Badge variant="secondary"> + <div className="text-sm font-medium"> {biddingStatusLabels[bidding.status as keyof typeof biddingStatusLabels] || bidding.status} - </Badge> + </div> </div> {/* 입찰담당자 */} diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx index 8351a0dd..9efa849b 100644 --- a/lib/bidding/selection/biddings-selection-columns.tsx +++ b/lib/bidding/selection/biddings-selection-columns.tsx @@ -221,20 +221,20 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): },
// ░░░ 참여업체수 ░░░
- {
- id: "participantCount",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여업체수" />,
- cell: ({ row }) => {
- const count = row.original.participantCount || 0
- return (
- <div className="flex items-center gap-1">
- <span className="text-sm font-medium">{count}</span>
- </div>
- )
- },
- size: 100,
- meta: { excelHeader: "참여업체수" },
- },
+ // {
+ // id: "participantCount",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여업체수" />,
+ // cell: ({ row }) => {
+ // const count = row.original.participantCount || 0
+ // return (
+ // <div className="flex items-center gap-1">
+ // <span className="text-sm font-medium">{count}</span>
+ // </div>
+ // )
+ // },
+ // size: 100,
+ // meta: { excelHeader: "참여업체수" },
+ // },
// ═══════════════════════════════════════════════════════════════
@@ -256,24 +256,6 @@ export function getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): <Eye className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
- {/* {row.original.status === 'bidding_opened' && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "close_bidding" })}>
- <Calendar className="mr-2 h-4 w-4" />
- 입찰마감
- </DropdownMenuItem>
- </>
- )} */}
- {row.original.status === 'bidding_closed' && (
- <>
- <DropdownMenuSeparator />
- <DropdownMenuItem onClick={() => setRowAction({ row, type: "evaluate_bidding" })}>
- <DollarSign className="mr-2 h-4 w-4" />
- 평가하기
- </DropdownMenuItem>
- </>
- )}
</DropdownMenuContent>
</DropdownMenu>
),
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx index 9545fe09..c3990e7b 100644 --- a/lib/bidding/selection/biddings-selection-table.tsx +++ b/lib/bidding/selection/biddings-selection-table.tsx @@ -19,6 +19,7 @@ import { contractTypeLabels,
} from "@/db/schema"
import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+import { toast } from "@/hooks/use-toast"
type BiddingSelectionItem = {
id: number
@@ -83,17 +84,16 @@ export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps switch (rowAction.type) {
case "view":
// 상세 페이지로 이동
- router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
- break
- case "close_bidding":
- // 입찰마감 (추후 구현)
- console.log('입찰마감:', rowAction.row.original)
- break
- case "evaluate_bidding":
- // 평가하기 (추후 구현)
- console.log('평가하기:', rowAction.row.original)
- break
- default:
+ // 입찰평가중일때만 상세보기 가능
+ if (rowAction.row.original.status === 'evaluation_of_bidding') {
+ router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
+ } else {
+ toast({
+ title: '접근 제한',
+ description: '입찰평가중이 아닙니다.',
+ variant: 'destructive',
+ })
+ }
break
}
}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 14bed105..2474d464 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -865,8 +865,12 @@ export interface CreateBiddingInput extends CreateBiddingSchema { meetingFiles: File[] } | null + // 첨부파일들 (선택사항) + attachments?: File[] + vendorAttachments?: File[] + // noticeType 필드 명시적 추가 (CreateBiddingSchema에 포함되어 있지만 타입 추론 문제 해결) - noticeType?: 'standard' | 'facility' | 'unit_price' + noticeType: 'standard' | 'facility' | 'unit_price' // PR 아이템들 (선택사항) prItems?: Array<{ @@ -1420,10 +1424,80 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { } } } - + + // 4. 첨부파일들 저장 (있는 경우) + if (input.attachments && input.attachments.length > 0) { + for (const file of input.attachments) { + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${biddingId}/attachments/shi`, + originalName: file.name, + userId + }) + + if (saveResult.success) { + await tx.insert(biddingDocuments).values({ + biddingId, + documentType: 'evaluation_doc', // SHI용 문서 타입 + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + fileSize: saveResult.fileSize!, + mimeType: file.type, + filePath: saveResult.publicPath!, + title: `SHI용 첨부파일 - ${file.name}`, + description: 'SHI용 첨부파일', + isPublic: true, // 발주처 문서이므로 공개 + isRequired: false, + uploadedBy: userName, + }) + } else { + console.error(`Failed to save SHI attachment file: ${file.name}`, saveResult.error) + } + } catch (error) { + console.error(`Error saving SHI attachment file: ${file.name}`, error) + } + } + } + + // Vendor 첨부파일들 저장 (있는 경우) + if (input.vendorAttachments && input.vendorAttachments.length > 0) { + for (const file of input.vendorAttachments) { + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${biddingId}/attachments/vendor`, + originalName: file.name, + userId + }) + + if (saveResult.success) { + await tx.insert(biddingDocuments).values({ + biddingId, + documentType: 'company_proposal', // 협력업체용 문서 타입 + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + fileSize: saveResult.fileSize!, + mimeType: file.type, + filePath: saveResult.publicPath!, + title: `협력업체용 첨부파일 - ${file.name}`, + description: '협력업체용 첨부파일', + isPublic: true, // 발주처 문서이므로 공개 + isRequired: false, + uploadedBy: userName, + }) + } else { + console.error(`Failed to save vendor attachment file: ${file.name}`, saveResult.error) + } + } catch (error) { + console.error(`Error saving vendor attachment file: ${file.name}`, error) + } + } + } + // 캐시 무효화 revalidatePath('/evcp/bid') - + return { success: true, message: '입찰이 성공적으로 생성되었습니다.', @@ -3200,13 +3274,15 @@ export async function increaseRoundOrRebid(biddingId: number, userId: string | u newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}` } } + //원입찰번호는 -0n 제외하고 저장 + const originalBiddingNumber = existingBidding.biddingNumber.split('-')[0] // 3. 새로운 입찰 생성 (기존 정보 복제) const [newBidding] = await tx .insert(biddings) .values({ biddingNumber: newBiddingNumber, - originalBiddingNumber: existingBidding.biddingNumber, // 원입찰번호 설정 + originalBiddingNumber: originalBiddingNumber, // 원입찰번호 설정 revision: 0, biddingSourceType: existingBidding.biddingSourceType, diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts index 5cf296e1..f70e498e 100644 --- a/lib/bidding/validation.ts +++ b/lib/bidding/validation.ts @@ -79,7 +79,7 @@ export const createBiddingSchema = z.object({ }), biddingTypeCustom: z.string().optional(), awardCount: z.enum(biddings.awardCount.enumValues, { - required_error: "낙찰수를 선택해주세요" + required_error: "낙찰업체 수를 선택해주세요" }), // ✅ 가격 정보 (조회용으로 readonly 처리) diff --git a/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx index f5206c71..14d42a46 100644 --- a/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx +++ b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx @@ -57,12 +57,13 @@ const documentTypes = [ { value: 'specification', label: '사양서' }, { value: 'specification_meeting', label: '사양설명회' }, { value: 'contract_draft', label: '계약서 초안' }, + { value: 'company_proposal', label: '협력업체용 첨부파일' }, { value: 'financial_doc', label: '재무 관련 문서' }, { value: 'technical_doc', label: '기술 관련 문서' }, { value: 'certificate', label: '인증서류' }, { value: 'pr_document', label: 'PR 문서' }, { value: 'spec_document', label: 'SPEC 문서' }, - { value: 'evaluation_doc', label: '평가 관련 문서' }, + { value: 'evaluation_doc', label: 'SHI용 첨부파일' }, { value: 'bid_attachment', label: '입찰 첨부파일' }, { value: 'other', label: '기타' } ] @@ -183,7 +184,7 @@ export function PartnersBiddingAttachmentsDialog({ <TableHead>파일명</TableHead> <TableHead>크기</TableHead> <TableHead>업로드일</TableHead> - <TableHead>작성자</TableHead> + {/* <TableHead>작성자</TableHead> */} <TableHead className="w-24">작업</TableHead> </TableRow> </TableHeader> @@ -212,12 +213,12 @@ export function PartnersBiddingAttachmentsDialog({ {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} </div> </TableCell> - <TableCell className="text-sm text-gray-500"> + {/* <TableCell className="text-sm text-gray-500"> <div className="flex items-center gap-1"> <User className="w-3 h-3" /> {doc.uploadedBy} </div> - </TableCell> + </TableCell> */} <TableCell> <Button variant="outline" diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index 66c90eaf..0215bcb6 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -94,6 +94,7 @@ interface BiddingDetail { responseSubmittedAt: Date | null priceAdjustmentResponse: boolean | null // 연동제 적용 여부 isPreQuoteParticipated: boolean | null // 사전견적 참여 여부 + isPriceAdjustmentApplicableQuestion: boolean // 연동제 적용요건 문의 여부 } interface PrItem { @@ -485,7 +486,21 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD // 임시 저장 핸들러 const handleSaveDraft = async () => { if (!biddingDetail || !userId) return - + + // 제출 마감일 체크 + if (biddingDetail.submissionEndDate) { + const now = new Date() + const deadline = new Date(biddingDetail.submissionEndDate) + if (deadline < now) { + toast({ + title: "접근 제한", + description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return + } + } + // 입찰 마감 상태 체크 const biddingStatus = biddingDetail.status const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' @@ -606,6 +621,21 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const handleSubmitResponse = () => { if (!biddingDetail) return + + // 제출 마감일 체크 + if (biddingDetail.submissionEndDate) { + const now = new Date() + const deadline = new Date(biddingDetail.submissionEndDate) + if (deadline < now) { + toast({ + title: "접근 제한", + description: "제출 마감일이 지났습니다. 더 이상 입찰에 참여할 수 없습니다.", + variant: "destructive", + }) + return + } + } + // 입찰 마감 상태 체크 const biddingStatus = biddingDetail.status const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' @@ -661,6 +691,9 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, additionalProposals: responseData.additionalProposals, isFinalSubmission, // 최종제출 여부 추가 + // 연동제 데이터 추가 (연동제 적용요건 문의가 있는 경우만) + priceAdjustmentResponse: biddingDetail.isPriceAdjustmentApplicableQuestion ? responseData.priceAdjustmentResponse : undefined, + priceAdjustmentForm: biddingDetail.isPriceAdjustmentApplicableQuestion && responseData.priceAdjustmentResponse !== null ? priceAdjustmentForm : undefined, prItemQuotations: prItemQuotations.length > 0 ? prItemQuotations.map(q => ({ prItemId: q.prItemId, bidUnitPrice: q.bidUnitPrice, @@ -781,7 +814,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div> </div> <div> - <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label> + <Label className="text-sm font-medium text-muted-foreground">낙찰업체 수</Label> <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : biddingDetail.awardCount === 'multiple' ? '복수' : '미설정'}</div> </div> <div> @@ -816,7 +849,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD {/* 제출 마감일 D-day */} - {/* {biddingDetail.submissionEndDate && ( + {biddingDetail.submissionEndDate && ( <div className="pt-4 border-t"> <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label> {(() => { @@ -826,13 +859,13 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const timeLeft = deadline.getTime() - now.getTime() const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - + return ( <div className={`p-3 rounded-lg border-2 ${ - isExpired - ? 'border-red-200 bg-red-50' - : daysLeft <= 1 - ? 'border-orange-200 bg-orange-50' + isExpired + ? 'border-red-200 bg-red-50' + : daysLeft <= 1 + ? 'border-orange-200 bg-orange-50' : 'border-green-200 bg-green-50' }`}> <div className="flex items-center justify-between"> @@ -866,7 +899,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD ) })()} </div> - )} */} + )} {/* 일정 정보 */} <div className="pt-4 border-t"> @@ -991,12 +1024,12 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </div> </div> - <div> + {/* <div> <Label className="text-muted-foreground">연동제 적용</Label> <div className="mt-1 p-3 bg-muted rounded-md"> <p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p> </div> - </div> + </div> */} <div > @@ -1110,8 +1143,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD /> )} - {/* 연동제 적용 여부 - SHI가 연동제를 요구하고, 사전견적에서 답변하지 않은 경우만 표시 */} - {biddingConditions?.isPriceAdjustmentApplicable && biddingDetail.priceAdjustmentResponse === null && ( + {/* 연동제 적용 여부 - 협력업체 별 연동제 적용요건 문의 여부에 따라 표시 */} + {biddingDetail.isPriceAdjustmentApplicableQuestion && ( <> <div className="space-y-3 p-4 border rounded-lg bg-muted/30"> <Label className="font-semibold text-base">연동제 적용 여부 *</Label> @@ -1346,28 +1379,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </> )} - {/* 사전견적에서 이미 답변한 경우 - 읽기 전용으로 표시 */} - {biddingDetail.priceAdjustmentResponse !== null && ( - <Card> - <CardHeader> - <CardTitle className="text-lg">연동제 적용 정보 (사전견적 제출 완료)</CardTitle> - </CardHeader> - <CardContent> - <div className="p-4 bg-muted/30 rounded-lg"> - <div className="flex items-center gap-2 mb-2"> - <CheckCircle className="w-5 h-5 text-green-600" /> - <span className="font-semibold"> - {biddingDetail.priceAdjustmentResponse ? '연동제 적용' : '연동제 미적용'} - </span> - </div> - <p className="text-sm text-muted-foreground"> - 사전견적에서 이미 연동제 관련 정보를 제출하였습니다. 본입찰에서는 별도의 연동제 정보 입력이 필요하지 않습니다. - </p> - </div> - </CardContent> - </Card> - )} - {/* 최종제출 체크박스 */} {!biddingDetail.isFinalSubmission && ( <div className="flex items-center space-x-2 p-4 border rounded-lg bg-muted/30"> diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index ba8efae6..ba47ce50 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -119,7 +119,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL // 첨부파일 columnHelper.display({ id: 'attachments', - header: 'SHI 첨부파일', + header: '첨부파일', cell: ({ row }) => { const handleViewDocumentsClick = (e: React.MouseEvent) => { e.stopPropagation() |
