From 25749225689c3934bc10ad1e8285e13020b61282 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 4 Dec 2025 09:04:09 +0000 Subject: (최겸)구매 입찰, 계약 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/manage/bidding-companies-editor.tsx | 262 ++++++++++++++++++++- components/bidding/manage/bidding-items-editor.tsx | 181 +++++++++++++- .../bidding/manage/create-pre-quote-rfq-dialog.tsx | 38 ++- .../procurement-item-selector-dialog-single.tsx | 4 +- 4 files changed, 470 insertions(+), 15 deletions(-) (limited to 'components') diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx index 6634f528..4c3e6bbc 100644 --- a/components/bidding/manage/bidding-companies-editor.tsx +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Building, User, Plus, Trash2 } from 'lucide-react' +import { Building, User, Plus, Trash2, Users } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -11,7 +11,9 @@ import { createBiddingCompanyContact, deleteBiddingCompanyContact, getVendorContactsByVendorId, - updateBiddingCompanyPriceAdjustmentQuestion + updateBiddingCompanyPriceAdjustmentQuestion, + getBiddingCompaniesByBidPicId, + addBiddingCompanyFromOtherBidding } from '@/lib/bidding/service' import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service' import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' @@ -36,6 +38,7 @@ import { } from '@/components/ui/table' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector' interface QuotationVendor { id: number // biddingCompanies.id @@ -102,6 +105,26 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false) const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState(null) + // 협력사 멀티 선택 다이얼로그 + const [multiSelectDialogOpen, setMultiSelectDialogOpen] = React.useState(false) + const [selectedBidPic, setSelectedBidPic] = React.useState(undefined) + const [biddingCompaniesList, setBiddingCompaniesList] = React.useState>([]) + const [isLoadingBiddingCompanies, setIsLoadingBiddingCompanies] = React.useState(false) + const [selectedBiddingCompany, setSelectedBiddingCompany] = React.useState<{ + biddingId: number + companyId: number + } | null>(null) + const [selectedBiddingCompanyContacts, setSelectedBiddingCompanyContacts] = React.useState([]) + const [isLoadingCompanyContacts, setIsLoadingCompanyContacts] = React.useState(false) + // 업체 목록 다시 로딩 함수 const reloadVendors = React.useCallback(async () => { try { @@ -494,10 +517,16 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC

{!readonly && ( - +
+ + +
)} @@ -740,6 +769,227 @@ export function BiddingCompaniesEditor({ biddingId, readonly = false }: BiddingC + {/* 협력사 멀티 선택 다이얼로그 */} + + + + 참여협력사 선택 + + 입찰담당자를 선택하여 해당 담당자의 입찰 업체를 조회하고 선택할 수 있습니다. + + + +
+ {/* 입찰담당자 선택 */} +
+ + { + setSelectedBidPic(code) + if (code.user?.id) { + setIsLoadingBiddingCompanies(true) + try { + const result = await getBiddingCompaniesByBidPicId(code.user.id) + if (result.success && result.data) { + setBiddingCompaniesList(result.data) + } else { + toast.error(result.error || '입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } + } catch (error) { + console.error('Failed to load bidding companies:', error) + toast.error('입찰 업체 조회에 실패했습니다.') + setBiddingCompaniesList([]) + } finally { + setIsLoadingBiddingCompanies(false) + } + } + }} + placeholder="입찰담당자 선택" + disabled={readonly} + /> +
+ + {/* 입찰 업체 목록 */} + {isLoadingBiddingCompanies ? ( +
+ + 입찰 업체를 불러오는 중... +
+ ) : biddingCompaniesList.length === 0 && selectedBidPic ? ( +
+ 해당 입찰담당자의 입찰 업체가 없습니다. +
+ ) : biddingCompaniesList.length > 0 ? ( +
+ + + + 선택 + 입찰번호 + 입찰명 + 협력사코드 + 협력사명 + 입찰 업데이트일 + + + + {biddingCompaniesList.map((company) => { + const isSelected = selectedBiddingCompany?.biddingId === company.biddingId && + selectedBiddingCompany?.companyId === company.companyId + return ( + { + if (isSelected) { + setSelectedBiddingCompany(null) + setSelectedBiddingCompanyContacts([]) + return + } + setSelectedBiddingCompany({ + biddingId: company.biddingId, + companyId: company.companyId + }) + setIsLoadingCompanyContacts(true) + try { + const contactsResult = await getBiddingCompanyContacts(company.biddingId, company.companyId) + if (contactsResult.success && contactsResult.data) { + setSelectedBiddingCompanyContacts(contactsResult.data) + } else { + setSelectedBiddingCompanyContacts([]) + } + } catch (error) { + console.error('Failed to load company contacts:', error) + setSelectedBiddingCompanyContacts([]) + } finally { + setIsLoadingCompanyContacts(false) + } + }} + > + e.stopPropagation()}> + { + // 클릭 이벤트는 TableRow의 onClick에서 처리 + }} + disabled={readonly} + /> + + {company.biddingNumber} + {company.biddingTitle} + {company.vendorCode} + {company.vendorName} + + {company.updatedAt ? new Date(company.updatedAt).toLocaleDateString('ko-KR') : '-'} + + + ) + })} + +
+ + {/* 선택한 입찰 업체의 담당자 정보 */} + {selectedBiddingCompany !== null && ( +
+

담당자 정보

+ {isLoadingCompanyContacts ? ( +
+ + 담당자 정보를 불러오는 중... +
+ ) : selectedBiddingCompanyContacts.length === 0 ? ( +
등록된 담당자가 없습니다.
+ ) : ( +
+ {selectedBiddingCompanyContacts.map((contact) => ( +
+ {contact.contactName} + {contact.contactEmail} + {contact.contactNumber && ( + {contact.contactNumber} + )} +
+ ))} +
+ )} +
+ )} +
+ ) : null} +
+ + + + + +
+
+ {/* 벤더 담당자에서 추가 다이얼로그 */} diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx index 90e512d2..452cdc3c 100644 --- a/components/bidding/manage/bidding-items-editor.tsx +++ b/components/bidding/manage/bidding-items-editor.tsx @@ -1,7 +1,7 @@ 'use client' import * as React from 'react' -import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react' +import { Package, Plus, Trash2, Save, RefreshCw, FileText, FileSpreadsheet, Upload } from 'lucide-react' import { getPRItemsForBidding } from '@/lib/bidding/detail/service' import { updatePrItem } from '@/lib/bidding/detail/service' import { toast } from 'sonner' @@ -26,7 +26,7 @@ import { CostCenterSingleSelector } from '@/components/common/selectors/cost-cen import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector' // PR 아이템 정보 타입 (create-bidding-dialog와 동일) -interface PRItemInfo { +export interface PRItemInfo { id: number // 실제 DB ID prNumber?: string | null projectId?: number | null @@ -84,6 +84,16 @@ import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog' import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' +import { exportBiddingItemsToExcel } from '@/lib/bidding/manage/export-bidding-items-to-excel' +import { importBiddingItemsFromExcel } from '@/lib/bidding/manage/import-bidding-items-from-excel' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItemsEditorProps) { const { data: session } = useSession() @@ -114,6 +124,11 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems isPriceAdjustmentApplicable?: boolean | null sparePartOptions?: string | null } | null>(null) + const [importDialogOpen, setImportDialogOpen] = React.useState(false) + const [importFile, setImportFile] = React.useState(null) + const [importErrors, setImportErrors] = React.useState([]) + const [isImporting, setIsImporting] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드 React.useEffect(() => { @@ -492,7 +507,7 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems materialGroupInfo: null, materialNumber: null, materialInfo: null, - priceUnit: 1, + priceUnit: '1', purchaseUnit: 'EA', materialWeight: null, wbsCode: null, @@ -644,6 +659,76 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems const totals = calculateTotals() + // Excel 내보내기 핸들러 + const handleExport = React.useCallback(async () => { + if (items.length === 0) { + toast.error('내보낼 품목이 없습니다.') + return + } + + try { + setIsExporting(true) + await exportBiddingItemsToExcel(items, { + filename: `입찰품목목록_${biddingId}`, + }) + toast.success('Excel 파일이 다운로드되었습니다.') + } catch (error) { + console.error('Excel export error:', error) + toast.error('Excel 내보내기 중 오류가 발생했습니다.') + } finally { + setIsExporting(false) + } + }, [items, biddingId]) + + // Excel 가져오기 핸들러 + const handleImportFileSelect = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (file) { + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + setImportFile(file) + setImportErrors([]) + } + } + + const handleImport = async () => { + if (!importFile) return + + setIsImporting(true) + setImportErrors([]) + + try { + const result = await importBiddingItemsFromExcel(importFile) + + if (result.errors.length > 0) { + setImportErrors(result.errors) + toast.warning( + `${result.items.length}개의 품목을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.` + ) + return + } + + if (result.items.length === 0) { + toast.error('가져올 품목이 없습니다.') + return + } + + // 기존 아이템에 추가 + setItems((prev) => [...prev, ...result.items]) + setImportDialogOpen(false) + setImportFile(null) + setImportErrors([]) + toast.success(`${result.items.length}개의 품목이 추가되었습니다.`) + } catch (error) { + console.error('Excel import error:', error) + toast.error('Excel 가져오기 중 오류가 발생했습니다.') + } finally { + setIsImporting(false) + } + } + if (isLoading) { return (
@@ -1372,6 +1457,14 @@ export function BiddingItemsEditor({ biddingId, readonly = false }: BiddingItems 사전견적 + + + + + +
) diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx index de3c19ff..1ab7a40f 100644 --- a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx +++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx @@ -465,7 +465,7 @@ export function CreatePreQuoteRfqDialog({ )} > {field.value ? ( - format(field.value, "yyyy-MM-dd") + format(field.value, "yyyy-MM-dd HH:mm") ) : ( 제출마감일을 선택하세요 (선택) )} @@ -477,12 +477,40 @@ export function CreatePreQuoteRfqDialog({ - date < new Date() || date < new Date("1900-01-01") - } + onSelect={(date) => { + if (!date) { + field.onChange(undefined) + return + } + const newDate = new Date(date) + if (field.value) { + newDate.setHours(field.value.getHours(), field.value.getMinutes()) + } else { + newDate.setHours(0, 0, 0, 0) + } + field.onChange(newDate) + }} + disabled={(date) => { + const today = new Date() + today.setHours(0, 0, 0, 0) + return date < today || date < new Date("1900-01-01") + }} initialFocus /> +
+ { + if (field.value) { + const [hours, minutes] = e.target.value.split(':').map(Number) + const newDate = new Date(field.value) + newDate.setHours(hours, minutes) + field.onChange(newDate) + } + }} + /> +
diff --git a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx index 84fd85ff..a1b98468 100644 --- a/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx +++ b/components/common/selectors/procurement-item/procurement-item-selector-dialog-single.tsx @@ -23,6 +23,7 @@ export interface ProcurementItemSelectorDialogSingleProps { title?: string; description?: string; showConfirmButtons?: boolean; + disabled?: boolean; } /** @@ -78,6 +79,7 @@ export function ProcurementItemSelectorDialogSingle({ title = "1회성 품목 선택", description = "1회성 품목을 검색하고 선택해주세요.", showConfirmButtons = false, + disabled = false, }: ProcurementItemSelectorDialogSingleProps) { const [open, setOpen] = useState(false); const [tempSelectedProcurementItem, setTempSelectedProcurementItem] = @@ -128,7 +130,7 @@ export function ProcurementItemSelectorDialogSingle({ return ( -