From ba8cd44a0ed2c613a5f2cee06bfc9bd0f61f21c7 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 7 Nov 2025 08:39:04 +0000 Subject: (최겸) 입찰/견적 수정사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bidding/manage/bidding-companies-editor.tsx | 803 +++++++++++++++++++++ 1 file changed, 803 insertions(+) create mode 100644 components/bidding/manage/bidding-companies-editor.tsx (limited to 'components/bidding/manage/bidding-companies-editor.tsx') diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx new file mode 100644 index 00000000..1ce8b014 --- /dev/null +++ b/components/bidding/manage/bidding-companies-editor.tsx @@ -0,0 +1,803 @@ +'use client' + +import * as React from 'react' +import { Building, User, Plus, Trash2 } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { + getBiddingVendors, + getBiddingCompanyContacts, + createBiddingCompanyContact, + deleteBiddingCompanyContact, + getVendorContactsByVendorId, + updateBiddingCompanyPriceAdjustmentQuestion +} from '@/lib/bidding/service' +import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service' +import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Checkbox } from '@/components/ui/checkbox' +import { Loader2 } from 'lucide-react' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +interface QuotationVendor { + id: number // biddingCompanies.id + companyId?: number // vendors.id (벤더 ID) + vendorName: string + vendorCode: string + contactPerson?: string + contactEmail?: string + contactPhone?: string + quotationAmount?: number + currency: string + invitationStatus: string + isPriceAdjustmentApplicableQuestion?: boolean +} + +interface BiddingCompaniesEditorProps { + biddingId: number +} + +interface VendorContact { + id: number + vendorId: number + contactName: string + contactPosition: string | null + contactDepartment: string | null + contactTask: string | null + contactEmail: string + contactPhone: string | null + isPrimary: boolean +} + +interface BiddingCompanyContact { + id: number + biddingId: number + vendorId: number + contactName: string + contactEmail: string + contactNumber: string | null + createdAt: Date + updatedAt: Date +} + +export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProps) { + const [vendors, setVendors] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + const [addVendorDialogOpen, setAddVendorDialogOpen] = React.useState(false) + const [selectedVendor, setSelectedVendor] = React.useState(null) + const [biddingCompanyContacts, setBiddingCompanyContacts] = React.useState([]) + const [isLoadingContacts, setIsLoadingContacts] = React.useState(false) + // 각 업체별 첫 번째 담당자 정보 저장 (vendorId -> 첫 번째 담당자) + const [vendorFirstContacts, setVendorFirstContacts] = React.useState>(new Map()) + + // 담당자 추가 다이얼로그 + const [addContactDialogOpen, setAddContactDialogOpen] = React.useState(false) + const [newContact, setNewContact] = React.useState({ + contactName: '', + contactEmail: '', + contactNumber: '', + }) + const [addContactFromVendorDialogOpen, setAddContactFromVendorDialogOpen] = React.useState(false) + const [vendorContacts, setVendorContacts] = React.useState([]) + const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false) + const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState(null) + + // 업체 목록 다시 로딩 함수 + const reloadVendors = React.useCallback(async () => { + try { + const result = await getBiddingVendors(biddingId) + if (result.success && result.data) { + const vendorsList = result.data.map(v => ({ + ...v, + companyId: v.companyId || undefined, + vendorName: v.vendorName || '', + vendorCode: v.vendorCode || '', + contactPerson: v.contactPerson ?? undefined, + contactEmail: v.contactEmail ?? undefined, + contactPhone: v.contactPhone ?? undefined, + quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined, + isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, + })) + setVendors(vendorsList) + + // 각 업체별 첫 번째 담당자 정보 로드 + const firstContactsMap = new Map() + const contactPromises = vendorsList + .filter(v => v.companyId) + .map(async (vendor) => { + try { + const contactResult = await getBiddingCompanyContacts(biddingId, vendor.companyId!) + if (contactResult.success && contactResult.data && contactResult.data.length > 0) { + firstContactsMap.set(vendor.companyId!, contactResult.data[0]) + } + } catch (error) { + console.error(`Failed to load contact for vendor ${vendor.companyId}:`, error) + } + }) + + await Promise.all(contactPromises) + setVendorFirstContacts(firstContactsMap) + } + } catch (error) { + console.error('Failed to reload vendors:', error) + } + }, [biddingId]) + + // 데이터 로딩 + React.useEffect(() => { + const loadVendors = async () => { + setIsLoading(true) + try { + const result = await getBiddingVendors(biddingId) + if (result.success && result.data) { + const vendorsList = result.data.map(v => ({ + id: v.id, + companyId: v.companyId || undefined, + vendorName: v.vendorName || '', + vendorCode: v.vendorCode || '', + contactPerson: v.contactPerson !== null ? v.contactPerson : undefined, + contactEmail: v.contactEmail !== null ? v.contactEmail : undefined, + contactPhone: v.contactPhone !== null ? v.contactPhone : undefined, + quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined, + currency: v.currency || 'KRW', + invitationStatus: v.invitationStatus, + isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false, + })) + setVendors(vendorsList) + + // 각 업체별 첫 번째 담당자 정보 로드 + const firstContactsMap = new Map() + const contactPromises = vendorsList + .filter(v => v.companyId) + .map(async (vendor) => { + try { + const contactResult = await getBiddingCompanyContacts(biddingId, vendor.companyId!) + if (contactResult.success && contactResult.data && contactResult.data.length > 0) { + firstContactsMap.set(vendor.companyId!, contactResult.data[0]) + } + } catch (error) { + console.error(`Failed to load contact for vendor ${vendor.companyId}:`, error) + } + }) + + await Promise.all(contactPromises) + setVendorFirstContacts(firstContactsMap) + } else { + toast.error(result.error || '업체 정보를 불러오는데 실패했습니다.') + setVendors([]) + } + } catch (error) { + console.error('Failed to load vendors:', error) + toast.error('업체 정보를 불러오는데 실패했습니다.') + setVendors([]) + } finally { + setIsLoading(false) + } + } + + loadVendors() + }, [biddingId]) + + // 업체 선택 핸들러 (단일 선택) + const handleVendorSelect = async (vendor: QuotationVendor) => { + // 이미 선택된 업체를 다시 클릭하면 선택 해제 + if (selectedVendor?.id === vendor.id) { + setSelectedVendor(null) + setBiddingCompanyContacts([]) + return + } + + // 새 업체 선택 + setSelectedVendor(vendor) + + // 선택한 업체의 담당자 목록 로딩 + if (vendor.companyId) { + setIsLoadingContacts(true) + try { + const result = await getBiddingCompanyContacts(biddingId, vendor.companyId) + if (result.success && result.data) { + setBiddingCompanyContacts(result.data) + } else { + toast.error(result.error || '담당자 목록을 불러오는데 실패했습니다.') + setBiddingCompanyContacts([]) + } + } catch (error) { + console.error('Failed to load contacts:', error) + toast.error('담당자 목록을 불러오는데 실패했습니다.') + setBiddingCompanyContacts([]) + } finally { + setIsLoadingContacts(false) + } + } + } + + // 업체 삭제 + const handleRemoveVendor = async (vendorId: number) => { + if (!confirm('정말로 이 업체를 삭제하시겠습니까?')) { + return + } + + try { + const result = await deleteBiddingCompany(vendorId) + if (result.success) { + toast.success('업체가 삭제되었습니다.') + // 업체 목록 다시 로딩 + await reloadVendors() + // 선택된 업체가 삭제된 경우 담당자 목록도 초기화 + if (selectedVendor?.id === vendorId) { + setSelectedVendor(null) + setBiddingCompanyContacts([]) + } + } else { + toast.error(result.error || '업체 삭제에 실패했습니다.') + } + } catch (error) { + console.error('Failed to remove vendor:', error) + toast.error('업체 삭제에 실패했습니다.') + } + } + + // 담당자 추가 (직접 입력) + const handleAddContact = async () => { + if (!selectedVendor || !selectedVendor.companyId) { + toast.error('업체를 선택해주세요.') + return + } + + if (!newContact.contactName || !newContact.contactEmail) { + toast.error('이름과 이메일은 필수입니다.') + return + } + + try { + const result = await createBiddingCompanyContact( + biddingId, + selectedVendor.companyId, + { + contactName: newContact.contactName, + contactEmail: newContact.contactEmail, + contactNumber: newContact.contactNumber || undefined, + } + ) + + if (result.success) { + toast.success('담당자가 추가되었습니다.') + setAddContactDialogOpen(false) + setNewContact({ contactName: '', contactEmail: '', contactNumber: '' }) + + // 담당자 목록 새로고침 + const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId) + if (contactsResult.success && contactsResult.data) { + setBiddingCompanyContacts(contactsResult.data) + // 첫 번째 담당자 정보 업데이트 + if (contactsResult.data.length > 0) { + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.set(selectedVendor.companyId!, contactsResult.data[0]) + return newMap + }) + } + } + } else { + toast.error(result.error || '담당자 추가에 실패했습니다.') + } + } catch (error) { + console.error('Failed to add contact:', error) + toast.error('담당자 추가에 실패했습니다.') + } + } + + // 담당자 추가 (벤더 목록에서 선택) + const handleOpenAddContactFromVendor = async () => { + if (!selectedVendor || !selectedVendor.companyId) { + toast.error('업체를 선택해주세요.') + return + } + + setIsLoadingVendorContacts(true) + setAddContactFromVendorDialogOpen(true) + setSelectedContactFromVendor(null) + + try { + const result = await getVendorContactsByVendorId(selectedVendor.companyId) + if (result.success && result.data) { + setVendorContacts(result.data) + } else { + toast.error(result.error || '벤더 담당자 목록을 불러오는데 실패했습니다.') + setVendorContacts([]) + } + } catch (error) { + console.error('Failed to load vendor contacts:', error) + toast.error('벤더 담당자 목록을 불러오는데 실패했습니다.') + setVendorContacts([]) + } finally { + setIsLoadingVendorContacts(false) + } + } + + // 벤더 담당자 선택 후 저장 + const handleAddContactFromVendor = async () => { + if (!selectedContactFromVendor || !selectedVendor || !selectedVendor.companyId) { + toast.error('담당자를 선택해주세요.') + return + } + + try { + const result = await createBiddingCompanyContact( + biddingId, + selectedVendor.companyId, + { + contactName: selectedContactFromVendor.contactName, + contactEmail: selectedContactFromVendor.contactEmail, + contactNumber: selectedContactFromVendor.contactPhone || undefined, + } + ) + + if (result.success) { + toast.success('담당자가 추가되었습니다.') + setAddContactFromVendorDialogOpen(false) + setSelectedContactFromVendor(null) + + // 담당자 목록 새로고침 + const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId) + if (contactsResult.success && contactsResult.data) { + setBiddingCompanyContacts(contactsResult.data) + // 첫 번째 담당자 정보 업데이트 + if (contactsResult.data.length > 0) { + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.set(selectedVendor.companyId!, contactsResult.data[0]) + return newMap + }) + } + } + } else { + toast.error(result.error || '담당자 추가에 실패했습니다.') + } + } catch (error) { + console.error('Failed to add contact:', error) + toast.error('담당자 추가에 실패했습니다.') + } + } + + // 담당자 삭제 + const handleDeleteContact = async (contactId: number) => { + if (!confirm('정말로 이 담당자를 삭제하시겠습니까?')) { + return + } + + try { + const result = await deleteBiddingCompanyContact(contactId) + if (result.success) { + toast.success('담당자가 삭제되었습니다.') + + // 담당자 목록 새로고침 + if (selectedVendor && selectedVendor.companyId) { + const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId) + if (contactsResult.success && contactsResult.data) { + setBiddingCompanyContacts(contactsResult.data) + // 첫 번째 담당자 정보 업데이트 + if (contactsResult.data.length > 0) { + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.set(selectedVendor.companyId!, contactsResult.data[0]) + return newMap + }) + } else { + // 담당자가 없으면 Map에서 제거 + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.delete(selectedVendor.companyId!) + return newMap + }) + } + } + } + } else { + toast.error(result.error || '담당자 삭제에 실패했습니다.') + } + } catch (error) { + console.error('Failed to delete contact:', error) + toast.error('담당자 삭제에 실패했습니다.') + } + } + + // 연동제 적용요건 문의 체크박스 변경 + const handleTogglePriceAdjustmentQuestion = async (vendorId: number, checked: boolean) => { + try { + const result = await updateBiddingCompanyPriceAdjustmentQuestion(vendorId, checked) + if (result.success) { + // 로컬 상태 업데이트 + setVendors(prev => prev.map(v => + v.id === vendorId + ? { ...v, isPriceAdjustmentApplicableQuestion: checked } + : v + )) + + // 선택된 업체 정보도 업데이트 + if (selectedVendor?.id === vendorId) { + setSelectedVendor(prev => prev ? { ...prev, isPriceAdjustmentApplicableQuestion: checked } : null) + } + + // 담당자 목록 새로고침 (첫 번째 담당자 정보 업데이트를 위해) + if (selectedVendor && selectedVendor.companyId) { + const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId) + if (contactsResult.success && contactsResult.data) { + setBiddingCompanyContacts(contactsResult.data) + if (contactsResult.data.length > 0) { + setVendorFirstContacts(prev => { + const newMap = new Map(prev) + newMap.set(selectedVendor.companyId!, contactsResult.data[0]) + return newMap + }) + } + } + } + } else { + toast.error(result.error || '연동제 적용요건 문의 여부 업데이트에 실패했습니다.') + } + } catch (error) { + console.error('Failed to update price adjustment question:', error) + toast.error('연동제 적용요건 문의 여부 업데이트에 실패했습니다.') + } + } + + if (isLoading) { + return ( +
+
+ 업체 정보를 불러오는 중... +
+ ) + } + + return ( +
+ {/* 참여 업체 목록 테이블 */} + + +
+ + + 참여 업체 목록 + +

+ 입찰에 참여하는 업체들을 관리합니다. 업체를 선택하면 하단에 담당자 목록이 표시됩니다. +

+
+ +
+ + {vendors.length === 0 ? ( +
+ 참여 업체가 없습니다. 업체를 추가해주세요. +
+ ) : ( + + + + 선택 + 업체명 + 업체코드 + 담당자 이름 + 담당자 이메일 + 담당자 연락처 + 상태 + 연동제 적용요건 문의 + 작업 + + + + {vendors.map((vendor) => ( + handleVendorSelect(vendor)} + > + e.stopPropagation()}> + handleVendorSelect(vendor)} + /> + + {vendor.vendorName} + {vendor.vendorCode} + + {vendor.companyId && vendorFirstContacts.has(vendor.companyId) + ? vendorFirstContacts.get(vendor.companyId)!.contactName + : '-'} + + + {vendor.companyId && vendorFirstContacts.has(vendor.companyId) + ? vendorFirstContacts.get(vendor.companyId)!.contactEmail + : '-'} + + + {vendor.companyId && vendorFirstContacts.has(vendor.companyId) + ? vendorFirstContacts.get(vendor.companyId)!.contactNumber || '-' + : '-'} + + + + + {vendor.invitationStatus} + + + +
+ + handleTogglePriceAdjustmentQuestion(vendor.id, checked as boolean) + } + /> + + {vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'} + +
+
+ + + +
+ ))} +
+
+ )} +
+
+ + {/* 선택한 업체의 담당자 목록 테이블 */} + {selectedVendor && ( + + +
+ + + {selectedVendor.vendorName} 담당자 목록 + +

+ 선택한 업체의 선정된 담당자를 관리합니다. +

+
+
+ + +
+
+ + {isLoadingContacts ? ( +
+ + 담당자 목록을 불러오는 중... +
+ ) : biddingCompanyContacts.length === 0 ? ( +
+ 등록된 담당자가 없습니다. 담당자를 추가해주세요. +
+ ) : ( + + + + 이름 + 이메일 + 전화번호 + 작업 + + + + {biddingCompanyContacts.map((biddingCompanyContact) => ( + + {biddingCompanyContact.contactName} + {biddingCompanyContact.contactEmail} + {biddingCompanyContact.contactNumber || '-'} + + + + + ))} + +
+ )} +
+
+ )} + + {/* 업체 추가 다이얼로그 */} + + + {/* 담당자 추가 다이얼로그 (직접 입력) */} + + + + 담당자 추가 + + 새로운 담당자 정보를 입력하세요. + + + +
+
+ + setNewContact(prev => ({ ...prev, contactName: e.target.value }))} + placeholder="담당자 이름" + /> +
+
+ + setNewContact(prev => ({ ...prev, contactEmail: e.target.value }))} + placeholder="example@email.com" + /> +
+
+ + setNewContact(prev => ({ ...prev, contactNumber: e.target.value }))} + placeholder="010-1234-5678" + /> +
+
+ + + + + +
+
+ + {/* 벤더 담당자에서 추가 다이얼로그 */} + + + + + {selectedVendor ? `${selectedVendor.vendorName} 벤더 담당자에서 선택` : '벤더 담당자 선택'} + + + 벤더에 등록된 담당자 목록에서 선택하세요. + + + +
+ {isLoadingVendorContacts ? ( +
+ + 담당자 목록을 불러오는 중... +
+ ) : vendorContacts.length === 0 ? ( +
+ 등록된 담당자가 없습니다. +
+ ) : ( +
+ {vendorContacts.map((contact) => ( +
setSelectedContactFromVendor(contact)} + > +
+ setSelectedContactFromVendor(contact)} + className="shrink-0" + /> +
+
+ {contact.contactName} + {contact.isPrimary && ( + + 주담당자 + + )} +
+ {contact.contactPosition && ( +

{contact.contactPosition}

+ )} +
+ {contact.contactEmail} + {contact.contactPhone && {contact.contactPhone}} +
+
+
+
+ ))} +
+ )} +
+ + + + + +
+
+
+ ) +} \ No newline at end of file -- cgit v1.2.3