summaryrefslogtreecommitdiff
path: root/components/bidding/manage
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2025-11-10 11:25:19 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2025-11-10 11:25:19 +0900
commita5501ad1d1cb836d2b2f84e9b0f06049e22c901e (patch)
tree667ed8c5d6ec35b109190e9f976d66ae54def4ce /components/bidding/manage
parentb0fe980376fcf1a19ff4b90851ca8b01f378fdc0 (diff)
parentf8a38907911d940cb2e8e6c9aa49488d05b2b578 (diff)
Merge remote-tracking branch 'origin/dujinkim' into master_homemaster
Diffstat (limited to 'components/bidding/manage')
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx1407
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx803
-rw-r--r--components/bidding/manage/bidding-detail-vendor-create-dialog.tsx437
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx1143
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx661
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx742
6 files changed, 5193 insertions, 0 deletions
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
new file mode 100644
index 00000000..d60c5d88
--- /dev/null
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -0,0 +1,1407 @@
+'use client'
+
+import * as React from 'react'
+import { useForm } from 'react-hook-form'
+import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+// CreateBiddingInput 타입 정의가 없으므로 CreateBiddingSchema를 확장하여 사용합니다.
+import { getBiddingById, updateBiddingBasicInfo, getBiddingConditions, getBiddingNotice, updateBiddingConditions } from '@/lib/bidding/service'
+import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
+import {
+ getIncotermsForSelection,
+ getPaymentTermsForSelection,
+ getPlaceOfShippingForSelection,
+ getPlaceOfDestinationForSelection,
+} from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeTypeLabels } from '@/db/schema'
+import TiptapEditor from '@/components/qna/tiptap-editor'
+import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code'
+import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
+import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
+import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
+import { getBiddingDocuments, uploadBiddingDocument, deleteBiddingDocument } from '@/lib/bidding/detail/service'
+import { downloadFile } from '@/lib/file-download'
+
+// 입찰 기본 정보 에디터 컴포넌트
+interface BiddingBasicInfo {
+ title?: string
+ description?: string
+ content?: string
+ noticeType?: string
+ contractType?: string
+ biddingType?: string
+ biddingTypeCustom?: string
+ awardCount?: string
+ budget?: string
+ finalBidPrice?: string
+ targetPrice?: string
+ prNumber?: string
+ contractStartDate?: string
+ contractEndDate?: string
+ submissionStartDate?: string
+ submissionEndDate?: string
+ evaluationDate?: string
+ hasSpecificationMeeting?: boolean
+ hasPrDocument?: boolean
+ currency?: string
+ purchasingOrganization?: string
+ bidPicName?: string
+ bidPicCode?: string
+ supplyPicName?: string
+ supplyPicCode?: string
+ requesterName?: string
+ remarks?: string
+}
+
+interface BiddingBasicInfoEditorProps {
+ biddingId: number
+}
+
+interface UploadedDocument {
+ id: number
+ biddingId: number
+ companyId: number | null
+ documentType: string
+ fileName: string
+ originalFileName: string
+ fileSize: number | null
+ filePath: string
+ title: string | null
+ description: string | null
+ uploadedAt: string
+ uploadedBy: string
+}
+
+export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProps) {
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false)
+ const [noticeTemplate, setNoticeTemplate] = React.useState('')
+
+ // 첨부파일 관련 상태
+ const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([])
+ const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([])
+ const [existingDocuments, setExistingDocuments] = React.useState<UploadedDocument[]>([])
+ const [isLoadingDocuments, setIsLoadingDocuments] = React.useState(false)
+
+ // 담당자 selector 상태
+ const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
+ const [selectedSupplyPic, setSelectedSupplyPic] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+
+ // 입찰 조건 관련 상태
+ const [biddingConditions, setBiddingConditions] = React.useState({
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+
+ // Procurement 데이터 상태들
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+
+ const form = useForm<BiddingBasicInfo>({
+ defaultValues: {}
+ })
+
+
+ // 공고 템플릿 로드 - 현재 저장된 템플릿 우선
+ const loadNoticeTemplate = React.useCallback(async (noticeType?: string) => {
+ setIsLoadingTemplate(true)
+ try {
+ // 먼저 현재 입찰에 저장된 템플릿이 있는지 확인
+ const savedNotice = await getBiddingNotice(biddingId)
+ if (savedNotice && savedNotice.content) {
+ setNoticeTemplate(savedNotice.content)
+ const currentContent = form.getValues('content')
+ if (!currentContent || currentContent.trim() === '') {
+ form.setValue('content', savedNotice.content)
+ }
+ setIsLoadingTemplate(false)
+ return
+ }
+
+ // 저장된 템플릿이 없으면 타입별 템플릿 로드
+ if (noticeType) {
+ const template = await getBiddingNoticeTemplate(noticeType)
+ if (template) {
+ setNoticeTemplate(template.content || '')
+ const currentContent = form.getValues('content')
+ if (!currentContent || currentContent.trim() === '') {
+ form.setValue('content', template.content || '')
+ }
+ } else {
+ // 템플릿이 없으면 표준 템플릿 사용
+ const defaultTemplate = await getBiddingNoticeTemplate('standard')
+ if (defaultTemplate) {
+ setNoticeTemplate(defaultTemplate.content)
+ const currentContent = form.getValues('content')
+ if (!currentContent || currentContent.trim() === '') {
+ form.setValue('content', defaultTemplate.content)
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.warn('Failed to load notice template:', error)
+ } finally {
+ setIsLoadingTemplate(false)
+ }
+ }, [biddingId, form])
+
+ // 데이터 로딩
+ React.useEffect(() => {
+ const loadBiddingData = async () => {
+ setIsLoading(true)
+ try {
+ const bidding = await getBiddingById(biddingId)
+ if (bidding) {
+ // 타입 확장된 bidding 객체
+ const biddingExtended = bidding as typeof bidding & {
+ content?: string | null
+ noticeType?: string | null
+ biddingTypeCustom?: string | null
+ awardCount?: string | null
+ requesterName?: string | null
+ }
+
+ // 날짜를 문자열로 변환하는 헬퍼
+ const formatDate = (date: unknown): string => {
+ if (!date) return ''
+ if (typeof date === 'string') return date.split('T')[0]
+ if (date instanceof Date) return date.toISOString().split('T')[0]
+ return ''
+ }
+
+ const formatDateTime = (date: unknown): string => {
+ if (!date) return ''
+ if (typeof date === 'string') return date.slice(0, 16)
+ if (date instanceof Date) return date.toISOString().slice(0, 16)
+ return ''
+ }
+
+ // 폼 데이터 설정
+ form.reset({
+ title: bidding.title || '',
+ description: bidding.description || '',
+ content: biddingExtended.content || '',
+ noticeType: biddingExtended.noticeType || '',
+ contractType: bidding.contractType || '',
+ biddingType: bidding.biddingType || '',
+ biddingTypeCustom: biddingExtended.biddingTypeCustom || '',
+ awardCount: biddingExtended.awardCount || (bidding.awardCount ? String(bidding.awardCount) : ''),
+ budget: bidding.budget ? bidding.budget.toString() : '',
+ finalBidPrice: bidding.finalBidPrice ? bidding.finalBidPrice.toString() : '',
+ targetPrice: bidding.targetPrice ? bidding.targetPrice.toString() : '',
+ prNumber: bidding.prNumber || '',
+ contractStartDate: formatDate(bidding.contractStartDate),
+ contractEndDate: formatDate(bidding.contractEndDate),
+ submissionStartDate: formatDateTime(bidding.submissionStartDate),
+ submissionEndDate: formatDateTime(bidding.submissionEndDate),
+ evaluationDate: formatDateTime(bidding.evaluationDate),
+ hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
+ hasPrDocument: bidding.hasPrDocument || false,
+ currency: bidding.currency || 'KRW',
+ purchasingOrganization: bidding.purchasingOrganization || '',
+ bidPicName: bidding.bidPicName || '',
+ bidPicCode: bidding.bidPicCode || '',
+ supplyPicName: bidding.supplyPicName || '',
+ supplyPicCode: bidding.supplyPicCode || '',
+ requesterName: biddingExtended.requesterName || '',
+ remarks: bidding.remarks || '',
+ })
+
+ // 입찰 조건 로드
+ const conditions = await getBiddingConditions(biddingId)
+ if (conditions) {
+ setBiddingConditions({
+ paymentTerms: conditions.paymentTerms || '',
+ taxConditions: conditions.taxConditions || 'V1',
+ incoterms: conditions.incoterms || 'DAP',
+ incotermsOption: conditions.incotermsOption || '',
+ contractDeliveryDate: conditions.contractDeliveryDate
+ ? new Date(conditions.contractDeliveryDate).toISOString().split('T')[0]
+ : '',
+ shippingPort: conditions.shippingPort || '',
+ destinationPort: conditions.destinationPort || '',
+ isPriceAdjustmentApplicable: conditions.isPriceAdjustmentApplicable || false,
+ sparePartOptions: conditions.sparePartOptions || '',
+ })
+ }
+
+ // Procurement 데이터 로드
+ const [paymentTermsData, incotermsData, shippingData, destinationData] = await Promise.all([
+ getPaymentTermsForSelection().catch(() => []),
+ getIncotermsForSelection().catch(() => []),
+ getPlaceOfShippingForSelection().catch(() => []),
+ getPlaceOfDestinationForSelection().catch(() => []),
+ ])
+ setPaymentTermsOptions(paymentTermsData)
+ setIncotermsOptions(incotermsData)
+ setShippingPlaces(shippingData)
+ setDestinationPlaces(destinationData)
+
+ // 공고 템플릿 로드
+ await loadNoticeTemplate(biddingExtended.noticeType || undefined)
+ } else {
+ toast.error('입찰 정보를 찾을 수 없습니다.')
+ }
+ } catch (error) {
+ console.error('Error loading bidding data:', error)
+ toast.error('입찰 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadBiddingData()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [biddingId, loadNoticeTemplate])
+
+ // 구매유형 변경 시 템플릿 자동 로드
+ const noticeTypeValue = form.watch('noticeType')
+ React.useEffect(() => {
+ if (noticeTypeValue) {
+ loadNoticeTemplate(noticeTypeValue)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [noticeTypeValue])
+
+ // 기존 첨부파일 로드
+ const loadExistingDocuments = async () => {
+ setIsLoadingDocuments(true)
+ try {
+ const docs = await getBiddingDocuments(biddingId)
+ const mappedDocs = docs.map((doc) => ({
+ ...doc,
+ uploadedAt: doc.uploadedAt?.toString() || '',
+ uploadedBy: doc.uploadedBy || ''
+ }))
+ setExistingDocuments(mappedDocs)
+ } catch (error) {
+ console.error('Failed to load documents:', error)
+ toast.error('첨부파일 목록을 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoadingDocuments(false)
+ }
+ }
+
+ // 초기 로드 시 첨부파일도 함께 로드
+ React.useEffect(() => {
+ if (biddingId) {
+ loadExistingDocuments()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [biddingId])
+
+ // SHI용 파일 첨부 핸들러
+ const handleShiFileUpload = async (files: File[]) => {
+ try {
+ // 파일을 업로드하고 기존 문서 목록 갱신
+ for (const file of files) {
+ const result = await uploadBiddingDocument(
+ biddingId,
+ file,
+ 'bid_attachment',
+ file.name,
+ 'SHI용 첨부파일',
+ '1' // TODO: 실제 사용자 ID 가져오기
+ )
+ if (result.success) {
+ toast.success(`${file.name} 업로드 완료`)
+ }
+ }
+ await loadExistingDocuments()
+ setShiAttachmentFiles([])
+ } catch (error) {
+ console.error('Failed to upload SHI files:', error)
+ toast.error('파일 업로드에 실패했습니다.')
+ }
+ }
+
+ const removeShiFile = (index: number) => {
+ setShiAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 협력업체용 파일 첨부 핸들러
+ const handleVendorFileUpload = async (files: File[]) => {
+ try {
+ // 파일을 업로드하고 기존 문서 목록 갱신
+ for (const file of files) {
+ const result = await uploadBiddingDocument(
+ biddingId,
+ file,
+ 'bid_attachment',
+ file.name,
+ '협력업체용 첨부파일',
+ '1' // TODO: 실제 사용자 ID 가져오기
+ )
+ if (result.success) {
+ toast.success(`${file.name} 업로드 완료`)
+ }
+ }
+ await loadExistingDocuments()
+ setVendorAttachmentFiles([])
+ } catch (error) {
+ console.error('Failed to upload vendor files:', error)
+ toast.error('파일 업로드에 실패했습니다.')
+ }
+ }
+
+ const removeVendorFile = (index: number) => {
+ setVendorAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 파일 삭제
+ const handleDeleteDocument = async (documentId: number) => {
+ if (!confirm('이 파일을 삭제하시겠습니까?')) {
+ return
+ }
+
+ try {
+ const result = await deleteBiddingDocument(documentId, biddingId, '1') // TODO: 실제 사용자 ID 가져오기
+ if (result.success) {
+ toast.success('파일이 삭제되었습니다.')
+ await loadExistingDocuments()
+ } else {
+ toast.error(result.error || '파일 삭제에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to delete document:', error)
+ toast.error('파일 삭제에 실패했습니다.')
+ }
+ }
+
+ // 파일 다운로드
+ const handleDownloadDocument = async (document: UploadedDocument) => {
+ try {
+ await downloadFile(document.filePath, document.originalFileName, {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Failed to download document:', error)
+ toast.error('파일 다운로드에 실패했습니다.')
+ }
+ }
+
+ // 입찰담당자 선택 핸들러
+ const handleBidPicSelect = (code: PurchaseGroupCodeWithUser) => {
+ setSelectedBidPic(code)
+ form.setValue('bidPicName', code.DISPLAY_NAME || '')
+ form.setValue('bidPicCode', code.PURCHASE_GROUP_CODE || '')
+ }
+
+ // 조달담당자 선택 핸들러
+ const handleSupplyPicSelect = (manager: ProcurementManagerWithUser) => {
+ setSelectedSupplyPic(manager)
+ form.setValue('supplyPicName', manager.DISPLAY_NAME || '')
+ form.setValue('supplyPicCode', manager.PROCUREMENT_MANAGER_CODE || '')
+ }
+
+ // 파일 크기 포맷팅
+ const formatFileSize = (bytes: number | null) => {
+ if (!bytes) return '-'
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ // 저장 처리
+ const handleSave = async (data: BiddingBasicInfo) => {
+ setIsSubmitting(true)
+ try {
+ // 기본 정보 저장
+ const result = await updateBiddingBasicInfo(biddingId, data, '1') // TODO: 실제 사용자 ID 가져오기
+
+ if (result.success) {
+ // 입찰 조건 저장
+ const conditionsResult = await updateBiddingConditions(biddingId, {
+ paymentTerms: biddingConditions.paymentTerms,
+ taxConditions: biddingConditions.taxConditions,
+ incoterms: biddingConditions.incoterms,
+ incotermsOption: biddingConditions.incotermsOption,
+ contractDeliveryDate: biddingConditions.contractDeliveryDate || undefined,
+ shippingPort: biddingConditions.shippingPort,
+ destinationPort: biddingConditions.destinationPort,
+ isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable,
+ sparePartOptions: biddingConditions.sparePartOptions,
+ })
+
+ if (conditionsResult.success) {
+ toast.success('입찰 기본 정보와 조건이 성공적으로 저장되었습니다.')
+ } else {
+ const errorMessage = 'error' in conditionsResult ? conditionsResult.error : '입찰 조건 저장에 실패했습니다.'
+ toast.error(errorMessage)
+ }
+ } else {
+ const errorMessage = 'error' in result ? result.error : '입찰 기본 정보 저장에 실패했습니다.'
+ toast.error(errorMessage)
+ }
+ } catch (error) {
+ console.error('Failed to save bidding basic info:', error)
+ toast.error('입찰 기본 정보 저장에 실패했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">입찰 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 입찰 기본 정보
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 입찰의 기본 정보를 수정할 수 있습니다.
+ </p>
+ </CardHeader>
+ <CardContent>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSave)} className="space-y-4">
+ {/* 1행: 입찰명, PR번호, 입찰유형, 계약구분 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="title" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰명 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="입찰명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="prNumber" render={({ field }) => (
+ <FormItem>
+ <FormLabel>PR 번호</FormLabel>
+ <FormControl>
+ <Input placeholder="PR 번호를 입력하세요" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="biddingType" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰유형</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입찰유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="contractType" render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약구분</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(contractTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 기타 입찰유형 선택 시 직접입력 필드 */}
+ {form.watch('biddingType') === 'other' && (
+ <div className="grid grid-cols-4 gap-4">
+ <div></div>
+ <div></div>
+ <FormField control={form.control} name="biddingTypeCustom" render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="직접 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+ <div></div>
+ </div>
+ )}
+
+ {/* 2행: 예산, 실적가, 내정가, 낙찰수 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="budget" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 예산
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="예산 입력" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="finalBidPrice" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 실적가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="실적가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="targetPrice" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Eye className="h-3 w-3" />
+ 내정가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="내정가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="awardCount" render={({ field }) => (
+ <FormItem>
+ <FormLabel>낙찰수</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="낙찰수 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(awardCountLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 3행: 입찰담당자, 조달담당자, 구매조직, 통화 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="bidPicName" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 입찰담당자
+ </FormLabel>
+ <FormControl>
+ <PurchaseGroupCodeSelector
+ selectedCode={selectedBidPic}
+ onCodeSelect={(code) => {
+ handleBidPicSelect(code)
+ field.onChange(code.DISPLAY_NAME || '')
+ }}
+ placeholder="입찰담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="supplyPicName" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 조달담당자
+ </FormLabel>
+ <FormControl>
+ <ProcurementManagerSelector
+ selectedManager={selectedSupplyPic}
+ onManagerSelect={(manager) => {
+ handleSupplyPicSelect(manager)
+ field.onChange(manager.DISPLAY_NAME || '')
+ }}
+ placeholder="조달담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="purchasingOrganization" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Building className="h-3 w-3" />
+ 구매조직
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매조직 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="currency" render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="KRW">KRW (원)</SelectItem>
+ <SelectItem value="USD">USD (달러)</SelectItem>
+ <SelectItem value="EUR">EUR (유로)</SelectItem>
+ <SelectItem value="JPY">JPY (엔)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 구매유형 필드 추가 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="noticeType" render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매유형 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingNoticeTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+ <div></div>
+ <div></div>
+ <div></div>
+ </div>
+
+ {/* 4행: 계약기간 시작/종료, 입찰서 제출 시작/마감 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="contractStartDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 시작
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="contractEndDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 종료
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ {/* <FormField control={form.control} name="submissionStartDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰서 제출 시작</FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="submissionEndDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰서 제출 마감</FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} /> */}
+ </div>
+
+ {/* 5행: 개찰 일시, 사양설명회, PR문서 */}
+ {/* <div className="grid grid-cols-3 gap-4">
+ <FormField control={form.control} name="evaluationDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel>개찰 일시</FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} /> */}
+
+ {/* <FormField control={form.control} name="hasSpecificationMeeting" render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">사양설명회</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 사양설명회가 필요한 경우 체크
+ </div>
+ </div>
+ <FormControl>
+ <Switch checked={field.value} onCheckedChange={field.onChange} />
+ </FormControl>
+ </FormItem>
+ )} /> */}
+
+ {/* <FormField control={form.control} name="hasPrDocument" render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">PR 문서</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ PR 문서가 있는 경우 체크
+ </div>
+ </div>
+ <FormControl>
+ <Switch checked={field.value} onCheckedChange={field.onChange} />
+ </FormControl>
+ </FormItem>
+ )} /> */}
+ {/* </div> */}
+
+ {/* 입찰개요 */}
+ <div className="pt-2">
+ <FormField control={form.control} name="description" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰개요</FormLabel>
+ <FormControl>
+ <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={2} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 비고 */}
+ <div className="pt-2">
+ <FormField control={form.control} name="remarks" render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea placeholder="추가 사항이나 참고사항을 입력하세요" rows={3} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 입찰 조건 */}
+ <div className="pt-4 border-t">
+ <CardTitle className="text-lg mb-4">입찰 조건</CardTitle>
+
+ {/* 1행: SHI 지급조건, SHI 매입부가가치세 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <FormLabel>SHI 지급조건 <span className="text-red-500">*</span></FormLabel>
+ <Select
+ value={biddingConditions.paymentTerms}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ paymentTerms: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="지급조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <FormLabel>SHI 매입부가가치세 <span className="text-red-500">*</span></FormLabel>
+ <Select
+ value={biddingConditions.taxConditions}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ taxConditions: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="세금조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {TAX_CONDITIONS.map((condition) => (
+ <SelectItem key={condition.code} value={condition.code}>
+ {condition.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ {/* 2행: SHI 인도조건, SHI 인도조건2 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <FormLabel>SHI 인도조건 <span className="text-red-500">*</span></FormLabel>
+ <Select
+ value={biddingConditions.incoterms}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incoterms: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <FormLabel>SHI 인도조건2</FormLabel>
+ <Input
+ placeholder="인도조건 추가 정보"
+ value={biddingConditions.incotermsOption}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incotermsOption: e.target.value
+ }))
+ }}
+ />
+ </div>
+ </div>
+
+ {/* 3행: SHI 선적지, SHI 하역지 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <FormLabel>SHI 선적지</FormLabel>
+ <Select
+ value={biddingConditions.shippingPort}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ shippingPort: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선적지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <FormLabel>SHI 하역지</FormLabel>
+ <Select
+ value={biddingConditions.destinationPort}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ destinationPort: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="하역지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ {/* 4행: 계약 납품일, 연동제 적용 가능 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <FormLabel>계약 납품일</FormLabel>
+ <Input
+ type="date"
+ value={biddingConditions.contractDeliveryDate}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))
+ }}
+ />
+ </div>
+
+ <div className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">연동제 적용 가능</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 연동제 적용 요건 여부
+ </div>
+ </div>
+ <Switch
+ checked={biddingConditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))
+ }}
+ />
+ </div>
+ </div>
+
+ {/* 5행: 스페어파트 옵션 */}
+ <div className="mb-4">
+ <div>
+ <FormLabel>스페어파트 옵션</FormLabel>
+ <Textarea
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={biddingConditions.sparePartOptions}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))
+ }}
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 입찰공고 내용 */}
+ <div className="pt-4 border-t">
+ <CardTitle className="text-lg mb-4">입찰공고 내용</CardTitle>
+ <FormField control={form.control} name="content" render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <div className="border rounded-lg">
+ <TiptapEditor
+ content={field.value || noticeTemplate}
+ setContent={field.onChange}
+ disabled={isLoadingTemplate}
+ height="300px"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ {isLoadingTemplate && (
+ <div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
+ 입찰공고 템플릿을 불러오는 중...
+ </div>
+ )}
+ </div>
+
+ {/* 액션 버튼 */}
+ <div className="flex justify-end gap-4 pt-4">
+ <Button type="submit" disabled={isSubmitting} className="flex items-center gap-2">
+ {isSubmitting ? '저장 중...' : '저장'}
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
+
+ {/* SHI용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ SHI용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ SHI에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={(e) => {
+ const files = Array.from(e.target.files || [])
+ setShiAttachmentFiles(prev => [...prev, ...files])
+ }}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {shiAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드 예정 파일</h4>
+ <div className="space-y-2">
+ {shiAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ handleShiFileUpload([file])
+ }}
+ >
+ 업로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeShiFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 기존 문서 목록 */}
+ {isLoadingDocuments ? (
+ <div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
+ 문서 목록을 불러오는 중...
+ </div>
+ ) : existingDocuments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 문서</h4>
+ <div className="space-y-2">
+ {existingDocuments
+ .filter(doc => doc.description?.includes('SHI용') || doc.title?.includes('SHI'))
+ .map((doc) => (
+ <div
+ key={doc.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{doc.originalFileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatFileSize(doc.fileSize)} • {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadDocument(doc)}
+ >
+ 다운로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteDocument(doc.id)}
+ >
+ 삭제
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 협력업체용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ 협력업체용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 협력업체에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={(e) => {
+ const files = Array.from(e.target.files || [])
+ setVendorAttachmentFiles(prev => [...prev, ...files])
+ }}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {vendorAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드 예정 파일</h4>
+ <div className="space-y-2">
+ {vendorAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ handleVendorFileUpload([file])
+ }}
+ >
+ 업로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeVendorFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 기존 문서 목록 */}
+ {existingDocuments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 문서</h4>
+ <div className="space-y-2">
+ {existingDocuments
+ .filter(doc => doc.description?.includes('협력업체용') || !doc.description?.includes('SHI용'))
+ .map((doc) => (
+ <div
+ key={doc.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{doc.originalFileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatFileSize(doc.fileSize)} • {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadDocument(doc)}
+ >
+ 다운로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteDocument(doc.id)}
+ >
+ 삭제
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </div>
+ )
+} \ No newline at end of file
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<QuotationVendor[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [addVendorDialogOpen, setAddVendorDialogOpen] = React.useState(false)
+ const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
+ const [biddingCompanyContacts, setBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([])
+ const [isLoadingContacts, setIsLoadingContacts] = React.useState(false)
+ // 각 업체별 첫 번째 담당자 정보 저장 (vendorId -> 첫 번째 담당자)
+ const [vendorFirstContacts, setVendorFirstContacts] = React.useState<Map<number, BiddingCompanyContact>>(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<VendorContact[]>([])
+ const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false)
+ const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(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<number, BiddingCompanyContact>()
+ 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<number, BiddingCompanyContact>()
+ 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 (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">업체 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 참여 업체 목록 테이블 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <Building className="h-5 w-5" />
+ 참여 업체 목록
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 입찰에 참여하는 업체들을 관리합니다. 업체를 선택하면 하단에 담당자 목록이 표시됩니다.
+ </p>
+ </div>
+ <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ 업체 추가
+ </Button>
+ </CardHeader>
+ <CardContent>
+ {vendors.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 참여 업체가 없습니다. 업체를 추가해주세요.
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px]">선택</TableHead>
+ <TableHead>업체명</TableHead>
+ <TableHead>업체코드</TableHead>
+ <TableHead>담당자 이름</TableHead>
+ <TableHead>담당자 이메일</TableHead>
+ <TableHead>담당자 연락처</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead className="w-[180px]">연동제 적용요건 문의</TableHead>
+ <TableHead className="w-[100px]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {vendors.map((vendor) => (
+ <TableRow
+ key={vendor.id}
+ className={`cursor-pointer hover:bg-muted/50 ${selectedVendor?.id === vendor.id ? 'bg-muted/50' : ''}`}
+ onClick={() => handleVendorSelect(vendor)}
+ >
+ <TableCell onClick={(e) => e.stopPropagation()}>
+ <Checkbox
+ checked={selectedVendor?.id === vendor.id}
+ onCheckedChange={() => handleVendorSelect(vendor)}
+ />
+ </TableCell>
+ <TableCell className="font-medium">{vendor.vendorName}</TableCell>
+ <TableCell>{vendor.vendorCode}</TableCell>
+ <TableCell>
+ {vendor.companyId && vendorFirstContacts.has(vendor.companyId)
+ ? vendorFirstContacts.get(vendor.companyId)!.contactName
+ : '-'}
+ </TableCell>
+ <TableCell>
+ {vendor.companyId && vendorFirstContacts.has(vendor.companyId)
+ ? vendorFirstContacts.get(vendor.companyId)!.contactEmail
+ : '-'}
+ </TableCell>
+ <TableCell>
+ {vendor.companyId && vendorFirstContacts.has(vendor.companyId)
+ ? vendorFirstContacts.get(vendor.companyId)!.contactNumber || '-'
+ : '-'}
+ </TableCell>
+
+ <TableCell>
+ <span className="px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
+ {vendor.invitationStatus}
+ </span>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={vendor.isPriceAdjustmentApplicableQuestion || false}
+ onCheckedChange={(checked) =>
+ handleTogglePriceAdjustmentQuestion(vendor.id, checked as boolean)
+ }
+ />
+ <span className="text-sm text-muted-foreground">
+ {vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ className="text-red-600 hover:text-red-800"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 선택한 업체의 담당자 목록 테이블 */}
+ {selectedVendor && (
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <User className="h-5 w-5" />
+ {selectedVendor.vendorName} 담당자 목록
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 선택한 업체의 선정된 담당자를 관리합니다.
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={handleOpenAddContactFromVendor}
+ className="flex items-center gap-2"
+ >
+ <User className="h-4 w-4" />
+ 업체 담당자 추가
+ </Button>
+ <Button
+ onClick={() => setAddContactDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 담당자 수기 입력
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent>
+ {isLoadingContacts ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">담당자 목록을 불러오는 중...</span>
+ </div>
+ ) : biddingCompanyContacts.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 등록된 담당자가 없습니다. 담당자를 추가해주세요.
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>이름</TableHead>
+ <TableHead>이메일</TableHead>
+ <TableHead>전화번호</TableHead>
+ <TableHead className="w-[100px]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {biddingCompanyContacts.map((biddingCompanyContact) => (
+ <TableRow key={biddingCompanyContact.id}>
+ <TableCell className="font-medium">{biddingCompanyContact.contactName}</TableCell>
+ <TableCell>{biddingCompanyContact.contactEmail}</TableCell>
+ <TableCell>{biddingCompanyContact.contactNumber || '-'}</TableCell>
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteContact(biddingCompanyContact.id)}
+ className="text-red-600 hover:text-red-800"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 업체 추가 다이얼로그 */}
+ <BiddingDetailVendorCreateDialog
+ biddingId={biddingId}
+ open={addVendorDialogOpen}
+ onOpenChange={setAddVendorDialogOpen}
+ onSuccess={reloadVendors}
+ />
+
+ {/* 담당자 추가 다이얼로그 (직접 입력) */}
+ <Dialog open={addContactDialogOpen} onOpenChange={setAddContactDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>담당자 추가</DialogTitle>
+ <DialogDescription>
+ 새로운 담당자 정보를 입력하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="space-y-2">
+ <Label htmlFor="contactName">이름 *</Label>
+ <Input
+ id="contactName"
+ value={newContact.contactName}
+ onChange={(e) => setNewContact(prev => ({ ...prev, contactName: e.target.value }))}
+ placeholder="담당자 이름"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contactEmail">이메일 *</Label>
+ <Input
+ id="contactEmail"
+ type="email"
+ value={newContact.contactEmail}
+ onChange={(e) => setNewContact(prev => ({ ...prev, contactEmail: e.target.value }))}
+ placeholder="example@email.com"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contactNumber">전화번호</Label>
+ <Input
+ id="contactNumber"
+ value={newContact.contactNumber}
+ onChange={(e) => setNewContact(prev => ({ ...prev, contactNumber: e.target.value }))}
+ placeholder="010-1234-5678"
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setAddContactDialogOpen(false)
+ setNewContact({ contactName: '', contactEmail: '', contactNumber: '' })
+ }}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleAddContact}>
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 벤더 담당자에서 추가 다이얼로그 */}
+ <Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}>
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>
+ {selectedVendor ? `${selectedVendor.vendorName} 벤더 담당자에서 선택` : '벤더 담당자 선택'}
+ </DialogTitle>
+ <DialogDescription>
+ 벤더에 등록된 담당자 목록에서 선택하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ {isLoadingVendorContacts ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">담당자 목록을 불러오는 중...</span>
+ </div>
+ ) : vendorContacts.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 등록된 담당자가 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {vendorContacts.map((contact) => (
+ <div
+ key={contact.id}
+ className={`flex items-center justify-between p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors ${
+ selectedContactFromVendor?.id === contact.id ? 'bg-primary/10 border-primary' : ''
+ }`}
+ onClick={() => setSelectedContactFromVendor(contact)}
+ >
+ <div className="flex items-center gap-3 flex-1">
+ <Checkbox
+ checked={selectedContactFromVendor?.id === contact.id}
+ onCheckedChange={() => setSelectedContactFromVendor(contact)}
+ className="shrink-0"
+ />
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ {contact.isPrimary && (
+ <span className="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary">
+ 주담당자
+ </span>
+ )}
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">{contact.contactPosition}</p>
+ )}
+ <div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
+ <span>{contact.contactEmail}</span>
+ {contact.contactPhone && <span>{contact.contactPhone}</span>}
+ </div>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setAddContactFromVendorDialogOpen(false)
+ setSelectedContactFromVendor(null)
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleAddContactFromVendor}
+ disabled={!selectedContactFromVendor || isLoadingVendorContacts}
+ >
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
new file mode 100644
index 00000000..ed3e2be6
--- /dev/null
+++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
@@ -0,0 +1,437 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { ChevronsUpDown, Loader2, X, Plus } from 'lucide-react'
+import { createBiddingDetailVendor } from '@/lib/bidding/detail/service'
+import { searchVendorsForBidding } from '@/lib/bidding/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+
+interface BiddingDetailVendorCreateDialogProps {
+ biddingId: number
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess: () => void
+}
+
+interface Vendor {
+ id: number
+ vendorName: string
+ vendorCode: string
+ status: string
+}
+
+interface SelectedVendorWithQuestion {
+ vendor: Vendor
+ isPriceAdjustmentApplicableQuestion: boolean
+}
+
+export function BiddingDetailVendorCreateDialog({
+ biddingId,
+ open,
+ onOpenChange,
+ onSuccess
+}: BiddingDetailVendorCreateDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [activeTab, setActiveTab] = React.useState('select')
+
+ // Vendor 검색 상태
+ const [vendorList, setVendorList] = React.useState<Vendor[]>([])
+ const [selectedVendorsWithQuestion, setSelectedVendorsWithQuestion] = React.useState<SelectedVendorWithQuestion[]>([])
+ const [vendorOpen, setVendorOpen] = React.useState(false)
+
+ // 벤더 로드
+ const loadVendors = React.useCallback(async () => {
+ try {
+ const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드
+ setVendorList(result || [])
+ } catch (error) {
+ console.error('Failed to load vendors:', error)
+ toast({
+ title: '오류',
+ description: '벤더 목록을 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ setVendorList([])
+ }
+ }, [biddingId, toast])
+
+ React.useEffect(() => {
+ if (open) {
+ loadVendors()
+ }
+ }, [open, loadVendors])
+
+ // 초기화
+ React.useEffect(() => {
+ if (!open) {
+ setSelectedVendorsWithQuestion([])
+ setActiveTab('select')
+ }
+ }, [open])
+
+ // 벤더 추가
+ const handleAddVendor = (vendor: Vendor) => {
+ if (!selectedVendorsWithQuestion.find(v => v.vendor.id === vendor.id)) {
+ setSelectedVendorsWithQuestion([
+ ...selectedVendorsWithQuestion,
+ {
+ vendor,
+ isPriceAdjustmentApplicableQuestion: false
+ }
+ ])
+ }
+ setVendorOpen(false)
+ }
+
+ // 벤더 제거
+ const handleRemoveVendor = (vendorId: number) => {
+ setSelectedVendorsWithQuestion(
+ selectedVendorsWithQuestion.filter(v => v.vendor.id !== vendorId)
+ )
+ }
+
+ // 이미 선택된 벤더인지 확인
+ const isVendorSelected = (vendorId: number) => {
+ return selectedVendorsWithQuestion.some(v => v.vendor.id === vendorId)
+ }
+
+ // 연동제 적용요건 문의 체크박스 토글
+ const handleTogglePriceAdjustmentQuestion = (vendorId: number, checked: boolean) => {
+ setSelectedVendorsWithQuestion(prev =>
+ prev.map(item =>
+ item.vendor.id === vendorId
+ ? { ...item, isPriceAdjustmentApplicableQuestion: checked }
+ : item
+ )
+ )
+ }
+
+ const handleCreate = () => {
+ if (selectedVendorsWithQuestion.length === 0) {
+ toast({
+ title: '오류',
+ description: '업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // Tab 2로 이동하여 연동제 적용요건 문의를 확인하도록 유도
+ if (activeTab === 'select') {
+ setActiveTab('question')
+ toast({
+ title: '확인 필요',
+ description: '선택한 업체들의 연동제 적용요건 문의를 확인해주세요.',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ let successCount = 0
+ const errorMessages: string[] = []
+
+ for (const item of selectedVendorsWithQuestion) {
+ try {
+ const response = await createBiddingDetailVendor(
+ biddingId,
+ item.vendor.id,
+ item.isPriceAdjustmentApplicableQuestion
+ )
+
+ if (response.success) {
+ successCount++
+ } else {
+ errorMessages.push(`${item.vendor.vendorName}: ${response.error}`)
+ }
+ } catch {
+ errorMessages.push(`${item.vendor.vendorName}: 처리 중 오류가 발생했습니다.`)
+ }
+ }
+
+ if (successCount > 0) {
+ toast({
+ title: '성공',
+ description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`,
+ })
+ onOpenChange(false)
+ resetForm()
+ onSuccess()
+ }
+
+ if (errorMessages.length > 0 && successCount === 0) {
+ toast({
+ title: '오류',
+ description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const resetForm = () => {
+ setSelectedVendorsWithQuestion([])
+ setActiveTab('select')
+ }
+
+ const selectedVendors = selectedVendorsWithQuestion.map(item => item.vendor)
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col">
+ {/* 헤더 */}
+ <DialogHeader className="p-6 pb-0">
+ <DialogTitle>협력업체 추가</DialogTitle>
+ <DialogDescription>
+ 입찰에 참여할 업체를 선택하고 연동제 적용요건 문의를 설정하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 탭 네비게이션 */}
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col px-6">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="select">
+ 1. 입찰업체 선택 ({selectedVendors.length}개)
+ </TabsTrigger>
+ <TabsTrigger
+ value="question"
+ disabled={selectedVendors.length === 0}
+ >
+ 2. 연동제 적용요건 문의
+ </TabsTrigger>
+ </TabsList>
+
+ {/* Tab 1: 입찰업체 선택 */}
+ <TabsContent value="select" className="flex-1 overflow-y-auto mt-4 pb-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">업체 선택</CardTitle>
+ <CardDescription>
+ 입찰에 참여할 협력업체를 선택하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {/* 업체 추가 버튼 */}
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className="w-full justify-between"
+ disabled={vendorList.length === 0}
+ >
+ <span className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ 업체 선택하기
+ </span>
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[500px] p-0" align="start">
+ <Command>
+ <CommandInput placeholder="업체명 또는 코드로 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {vendorList
+ .filter(vendor => !isVendorSelected(vendor.id))
+ .map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorCode} ${vendor.vendorName}`}
+ onSelect={() => handleAddVendor(vendor)}
+ >
+ <div className="flex items-center gap-2 w-full">
+ <Badge variant="outline" className="shrink-0">
+ {vendor.vendorCode}
+ </Badge>
+ <span className="truncate">{vendor.vendorName}</span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ {/* 선택된 업체 목록 */}
+ {selectedVendors.length > 0 && (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4>
+ </div>
+ <div className="space-y-2">
+ {selectedVendors.map((vendor, index) => (
+ <div
+ key={vendor.id}
+ className="flex items-center justify-between p-3 rounded-lg bg-secondary/50"
+ >
+ <div className="flex items-center gap-3">
+ <span className="text-sm text-muted-foreground">
+ {index + 1}.
+ </span>
+ <Badge variant="outline">
+ {vendor.vendorCode}
+ </Badge>
+ <span className="text-sm font-medium">
+ {vendor.vendorName}
+ </span>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ className="h-8 w-8 p-0"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {selectedVendors.length === 0 && (
+ <div className="text-center py-8 text-muted-foreground">
+ <p className="text-sm">아직 선택된 업체가 없습니다.</p>
+ <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* Tab 2: 연동제 적용요건 문의 체크 */}
+ <TabsContent value="question" className="flex-1 overflow-y-auto mt-4 pb-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">연동제 적용요건 문의</CardTitle>
+ <CardDescription>
+ 선택한 업체별로 연동제 적용요건 문의 여부를 체크하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {selectedVendorsWithQuestion.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <p className="text-sm">선택된 업체가 없습니다.</p>
+ <p className="text-xs mt-1">먼저 입찰업체 선택 탭에서 업체를 선택해주세요.</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {selectedVendorsWithQuestion.map((item, index) => (
+ <div
+ key={item.vendor.id}
+ className="flex items-center justify-between p-4 rounded-lg border"
+ >
+ <div className="flex items-center gap-4 flex-1">
+ <span className="text-sm text-muted-foreground w-8">
+ {index + 1}.
+ </span>
+ <div className="flex-1">
+ <div className="flex items-center gap-2 mb-1">
+ <Badge variant="outline">
+ {item.vendor.vendorCode}
+ </Badge>
+ <span className="font-medium">{item.vendor.vendorName}</span>
+ </div>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Checkbox
+ id={`question-${item.vendor.id}`}
+ checked={item.isPriceAdjustmentApplicableQuestion}
+ onCheckedChange={(checked) =>
+ handleTogglePriceAdjustmentQuestion(item.vendor.id, checked as boolean)
+ }
+ />
+ <Label
+ htmlFor={`question-${item.vendor.id}`}
+ className="text-sm cursor-pointer"
+ >
+ 연동제 적용요건 문의
+ </Label>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+
+ {/* 푸터 */}
+ <DialogFooter className="p-6 pt-0 border-t">
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ {activeTab === 'select' ? (
+ <Button
+ onClick={() => {
+ if (selectedVendors.length > 0) {
+ setActiveTab('question')
+ } else {
+ toast({
+ title: '오류',
+ description: '업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ }
+ }}
+ disabled={isPending || selectedVendors.length === 0}
+ >
+ 다음 단계
+ </Button>
+ ) : (
+ <Button
+ onClick={handleCreate}
+ disabled={isPending || selectedVendorsWithQuestion.length === 0}
+ >
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {selectedVendorsWithQuestion.length > 0
+ ? `${selectedVendorsWithQuestion.length}개 업체 추가`
+ : '업체 추가'
+ }
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
new file mode 100644
index 00000000..96a8d2ae
--- /dev/null
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -0,0 +1,1143 @@
+'use client'
+
+import * as React from 'react'
+import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react'
+import { getPRItemsForBidding } from '@/lib/bidding/detail/service'
+import { updatePrItem } from '@/lib/bidding/detail/service'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Checkbox } from '@/components/ui/checkbox'
+import { ProjectSelector } from '@/components/ProjectSelector'
+import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
+import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single'
+import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
+import { CostCenterSingleSelector } from '@/components/common/selectors/cost-center/cost-center-single-selector'
+import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector'
+
+// PR 아이템 정보 타입 (create-bidding-dialog와 동일)
+interface PRItemInfo {
+ id: number // 실제 DB ID
+ prNumber?: string | null
+ projectId?: number | null
+ projectInfo?: string | null
+ shi?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ materialDescription?: string | null
+ hasSpecDocument?: boolean
+ requestedDeliveryDate?: string | null
+ isRepresentative?: boolean // 대표 아이템 여부
+ // 가격 정보
+ annualUnitPrice?: string | null
+ currency?: string | null
+ // 자재 그룹 정보 (필수)
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ // 자재 정보
+ materialNumber?: string | null
+ materialInfo?: string | null
+ // 단위 정보
+ priceUnit?: string | null
+ purchaseUnit?: string | null
+ materialWeight?: string | null
+ // WBS 정보
+ wbsCode?: string | null
+ wbsName?: string | null
+ // Cost Center 정보
+ costCenterCode?: string | null
+ costCenterName?: string | null
+ // GL Account 정보
+ glAccountCode?: string | null
+ glAccountName?: string | null
+ // 내정 정보
+ targetUnitPrice?: string | null
+ targetAmount?: string | null
+ targetCurrency?: string | null
+ // 예산 정보
+ budgetAmount?: string | null
+ budgetCurrency?: string | null
+ // 실적 정보
+ actualAmount?: string | null
+ actualCurrency?: string | null
+}
+
+interface BiddingItemsEditorProps {
+ biddingId: number
+}
+
+import { removeBiddingItem, addPRItemForBidding, getBiddingById, getBiddingConditions } from '@/lib/bidding/service'
+import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog'
+import { Textarea } from '@/components/ui/textarea'
+import { Label } from '@/components/ui/label'
+
+export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
+ const { data: session } = useSession()
+ const [items, setItems] = React.useState<PRItemInfo[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity')
+ const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false)
+ const [selectedItemForCostCenter, setSelectedItemForCostCenter] = React.useState<number | null>(null)
+ const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false)
+ const [selectedItemForGlAccount, setSelectedItemForGlAccount] = React.useState<number | null>(null)
+ const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false)
+ const [selectedItemForWbs, setSelectedItemForWbs] = React.useState<number | null>(null)
+ const [tempIdCounter, setTempIdCounter] = React.useState(0) // 임시 ID 카운터
+ const [deletedItemIds, setDeletedItemIds] = React.useState<Set<number>>(new Set()) // 삭제된 아이템 ID 추적
+ const [preQuoteDialogOpen, setPreQuoteDialogOpen] = React.useState(false)
+ const [targetPriceCalculationCriteria, setTargetPriceCalculationCriteria] = React.useState('')
+ const [biddingPicUserId, setBiddingPicUserId] = React.useState<number | null>(null)
+ const [biddingConditions, setBiddingConditions] = React.useState<{
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ } | null>(null)
+
+ // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드
+ React.useEffect(() => {
+ const loadItems = async () => {
+ if (!biddingId) return
+
+ setIsLoading(true)
+ try {
+ const prItems = await getPRItemsForBidding(biddingId)
+
+ if (prItems && prItems.length > 0) {
+ const formattedItems: PRItemInfo[] = prItems.map((item) => ({
+ id: item.id,
+ prNumber: item.prNumber || null,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ quantity: item.quantity ? item.quantity.toString() : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? item.totalWeight.toString() : null,
+ weightUnit: item.weightUnit || null,
+ materialDescription: item.itemInfo || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null,
+ isRepresentative: false, // 첫 번째 아이템을 대표로 설정할 수 있음
+ annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null,
+ currency: item.currency || 'KRW',
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? item.materialWeight.toString() : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null,
+ targetAmount: item.targetAmount ? item.targetAmount.toString() : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? item.actualAmount.toString() : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ }))
+
+ // 첫 번째 아이템을 대표로 설정
+ formattedItems[0].isRepresentative = true
+
+ setItems(formattedItems)
+ setDeletedItemIds(new Set()) // 삭제 목록 초기화
+
+ // 기존 품목 로드 성공 알림 (조용히 표시, 선택적)
+ console.log(`기존 품목 ${formattedItems.length}개를 불러왔습니다.`)
+ } else {
+ // 품목이 없을 때는 빈 배열로 초기화
+ setItems([])
+ setDeletedItemIds(new Set())
+ }
+ } catch (error) {
+ console.error('Failed to load items:', error)
+ toast.error('품목 정보를 불러오는데 실패했습니다.')
+ // 에러 발생 시에도 빈 배열로 초기화하여 UI가 깨지지 않도록
+ setItems([])
+ setDeletedItemIds(new Set())
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadItems()
+ }, [biddingId])
+
+ // 입찰 정보 및 조건 로드 (사전견적 다이얼로그용)
+ React.useEffect(() => {
+ const loadBiddingInfo = async () => {
+ if (!biddingId) return
+
+ try {
+ const [bidding, conditions] = await Promise.all([
+ getBiddingById(biddingId),
+ getBiddingConditions(biddingId)
+ ])
+
+ if (bidding) {
+ setBiddingPicUserId(bidding.bidPicId || null)
+ setTargetPriceCalculationCriteria(bidding.targetPriceCalculationCriteria || '')
+ }
+
+ if (conditions) {
+ setBiddingConditions(conditions)
+ }
+ } catch (error) {
+ console.error('Failed to load bidding info:', error)
+ }
+ }
+
+ loadBiddingInfo()
+ }, [biddingId])
+
+ const handleSave = async () => {
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+ let hasError = false
+
+ // 모든 아이템을 upsert 처리 (id가 있으면 update, 없으면 insert)
+ for (const item of items) {
+ const targetAmount = calculateTargetAmount(item)
+
+ let result
+ if (item.id > 0) {
+ // 기존 아이템 업데이트
+ result = await updatePrItem(item.id, {
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ quantity: item.quantity ? parseFloat(item.quantity) : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? parseFloat(item.materialWeight) : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : null,
+ targetAmount: targetAmount ? parseFloat(targetAmount) : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount) : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? parseFloat(item.actualAmount) : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate) : null,
+ currency: item.currency || 'KRW',
+ annualUnitPrice: item.annualUnitPrice ? parseFloat(item.annualUnitPrice) : null,
+ prNumber: item.prNumber || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ } as Parameters<typeof updatePrItem>[1], userId)
+ } else {
+ // 새 아이템 추가 (문자열 타입만 허용)
+ result = await addPRItemForBidding(biddingId, {
+ projectId: item.projectId ?? undefined,
+ projectInfo: item.projectInfo ?? null,
+ shi: item.shi ?? null,
+ materialGroupNumber: item.materialGroupNumber ?? null,
+ materialGroupInfo: item.materialGroupInfo ?? null,
+ materialNumber: item.materialNumber ?? null,
+ materialInfo: item.materialInfo ?? null,
+ quantity: item.quantity ?? null,
+ quantityUnit: item.quantityUnit ?? null,
+ totalWeight: item.totalWeight ?? null,
+ weightUnit: item.weightUnit ?? null,
+ priceUnit: item.priceUnit ?? null,
+ purchaseUnit: item.purchaseUnit ?? null,
+ materialWeight: item.materialWeight ?? null,
+ wbsCode: item.wbsCode ?? null,
+ wbsName: item.wbsName ?? null,
+ costCenterCode: item.costCenterCode ?? null,
+ costCenterName: item.costCenterName ?? null,
+ glAccountCode: item.glAccountCode ?? null,
+ glAccountName: item.glAccountName ?? null,
+ targetUnitPrice: item.targetUnitPrice ?? null,
+ targetAmount: targetAmount ?? null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ?? null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ?? null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ requestedDeliveryDate: item.requestedDeliveryDate ?? null,
+ currency: item.currency || 'KRW',
+ annualUnitPrice: item.annualUnitPrice ?? null,
+ prNumber: item.prNumber ?? null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ })
+ }
+
+ if (!result.success) {
+ hasError = true
+ }
+ }
+
+ // 삭제된 아이템들 서버에서 삭제
+ for (const deletedId of deletedItemIds) {
+ const result = await removeBiddingItem(deletedId)
+ if (!result.success) {
+ hasError = true
+ }
+ }
+
+
+ if (hasError) {
+ toast.error('일부 품목 정보 저장에 실패했습니다.')
+ } else {
+ // 내정가 산정 기준 별도 저장 (서버 액션으로 처리)
+ if (targetPriceCalculationCriteria.trim()) {
+ try {
+ const { updateTargetPriceCalculationCriteria } = await import('@/lib/bidding/service')
+ const criteriaResult = await updateTargetPriceCalculationCriteria(biddingId, targetPriceCalculationCriteria.trim(), userId)
+ if (!criteriaResult.success) {
+ console.warn('Failed to save target price calculation criteria:', criteriaResult.error)
+ }
+ } catch (error) {
+ console.error('Failed to save target price calculation criteria:', error)
+ }
+ }
+
+ toast.success('품목 정보가 성공적으로 저장되었습니다.')
+ // 삭제 목록 초기화
+ setDeletedItemIds(new Set())
+ // 데이터 다시 로딩하여 최신 상태 반영
+ const prItems = await getPRItemsForBidding(biddingId)
+ const formattedItems: PRItemInfo[] = prItems.map((item) => ({
+ id: item.id,
+ prNumber: item.prNumber || null,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ quantity: item.quantity ? item.quantity.toString() : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? item.totalWeight.toString() : null,
+ weightUnit: item.weightUnit || null,
+ materialDescription: item.itemInfo || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null,
+ isRepresentative: false,
+ annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null,
+ currency: item.currency || 'KRW',
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? item.materialWeight.toString() : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null,
+ targetAmount: item.targetAmount ? item.targetAmount.toString() : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? item.actualAmount.toString() : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ }))
+
+ // 첫 번째 아이템을 대표로 설정
+ if (formattedItems.length > 0) {
+ formattedItems[0].isRepresentative = true
+ }
+
+ setItems(formattedItems)
+ }
+ } catch (error) {
+ console.error('Failed to save items:', error)
+ toast.error('품목 정보 저장에 실패했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleAddItem = () => {
+ // 임시 ID 생성 (음수로 구분하여 실제 DB ID와 구분)
+ const tempId = -(tempIdCounter + 1)
+ setTempIdCounter(prev => prev + 1)
+
+ // 즉시 UI에 새 아이템 추가 (서버 저장 없음)
+ const newItem: PRItemInfo = {
+ id: tempId, // 임시 ID
+ prNumber: null,
+ projectId: null,
+ projectInfo: null,
+ shi: null,
+ quantity: null,
+ quantityUnit: 'EA',
+ totalWeight: null,
+ weightUnit: 'KG',
+ materialDescription: null,
+ hasSpecDocument: false,
+ requestedDeliveryDate: null,
+ isRepresentative: items.length === 0,
+ annualUnitPrice: null,
+ currency: 'KRW',
+ materialGroupNumber: null,
+ materialGroupInfo: null,
+ materialNumber: null,
+ materialInfo: null,
+ priceUnit: null,
+ purchaseUnit: '1',
+ materialWeight: null,
+ wbsCode: null,
+ wbsName: null,
+ costCenterCode: null,
+ costCenterName: null,
+ glAccountCode: null,
+ glAccountName: null,
+ targetUnitPrice: null,
+ targetAmount: null,
+ targetCurrency: 'KRW',
+ budgetAmount: null,
+ budgetCurrency: 'KRW',
+ actualAmount: null,
+ actualCurrency: 'KRW',
+ }
+
+ setItems((prev) => {
+ // 첫 번째 아이템이면 대표로 설정
+ if (prev.length === 0) {
+ return [newItem]
+ }
+ return [...prev, newItem]
+ })
+ }
+
+ const handleRemoveItem = (itemId: number) => {
+ if (items.length <= 1) {
+ toast.error('최소 하나의 품목이 필요합니다.')
+ return
+ }
+
+ // 실제 아이템인 경우 삭제 목록에 추가 (저장 시 서버에서 삭제됨)
+ if (itemId > 0) {
+ setDeletedItemIds(prev => new Set([...prev, itemId]))
+ }
+
+ // UI에서 즉시 제거
+ setItems((prev) => {
+ const filteredItems = prev.filter((item) => item.id !== itemId)
+ const removedItem = prev.find((item) => item.id === itemId)
+ if (removedItem?.isRepresentative && filteredItems.length > 0) {
+ filteredItems[0].isRepresentative = true
+ }
+ return filteredItems
+ })
+ }
+
+ const updatePRItem = (id: number, updates: Partial<PRItemInfo>) => {
+ setItems((prev) =>
+ prev.map((item) => {
+ if (item.id === id) {
+ const updatedItem = { ...item, ...updates }
+ // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산
+ if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) {
+ updatedItem.targetAmount = calculateTargetAmount(updatedItem)
+ }
+ return updatedItem
+ }
+ return item
+ })
+ )
+ }
+
+ const setRepresentativeItem = (id: number) => {
+ setItems((prev) =>
+ prev.map((item) => ({
+ ...item,
+ isRepresentative: item.id === id,
+ }))
+ )
+ }
+
+ const handleQuantityWeightModeChange = (mode: 'quantity' | 'weight') => {
+ setQuantityWeightMode(mode)
+ }
+
+ const calculateTargetAmount = (item: PRItemInfo) => {
+ const unitPrice = parseFloat(item.targetUnitPrice || '0') || 0
+ const purchaseUnit = parseFloat(item.purchaseUnit || '1') || 1
+ let amount = 0
+
+ if (quantityWeightMode === 'quantity') {
+ const quantity = parseFloat(item.quantity || '0') || 0
+ amount = (quantity / purchaseUnit) * unitPrice
+ } else {
+ const weight = parseFloat(item.totalWeight || '0') || 0
+ amount = (weight / purchaseUnit) * unitPrice
+ }
+
+ return Math.floor(amount).toString()
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">품목 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ // PR 아이템 테이블 렌더링 (create-bidding-dialog와 동일한 구조)
+ const renderPrItemsTable = () => {
+ return (
+ <div className="border rounded-lg overflow-hidden">
+ <div className="overflow-x-auto">
+ <table className="w-full border-collapse">
+ <thead className="bg-muted/50">
+ <tr>
+ <th className="sticky left-0 z-10 bg-muted/50 border-r px-2 py-3 text-left text-xs font-medium min-w-[50px]">
+ <span className="sr-only">대표</span>
+ </th>
+ <th className="sticky left-[50px] z-10 bg-muted/50 border-r px-3 py-3 text-left text-xs font-medium min-w-[40px]">
+ #
+ </th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">예산금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th>
+ <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]">
+ 액션
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {items.map((item, index) => (
+ <tr key={item.id} className="border-t hover:bg-muted/30">
+ <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center">
+ <Checkbox
+ checked={item.isRepresentative}
+ onCheckedChange={() => setRepresentativeItem(item.id)}
+ disabled={items.length <= 1 && item.isRepresentative}
+ title="대표 아이템"
+ />
+ </td>
+ <td className="sticky left-[50px] z-10 bg-background border-r px-3 py-2 text-xs text-muted-foreground">
+ {index + 1}
+ </td>
+ <td className="border-r px-3 py-2">
+ <ProjectSelector
+ selectedProjectId={item.projectId || null}
+ onProjectSelect={(project) => {
+ updatePRItem(item.id, {
+ projectId: project.id,
+ projectInfo: project.projectName
+ })
+ }}
+ placeholder="프로젝트 선택"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="프로젝트명"
+ value={item.projectInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupNumber ? {
+ materialGroupCode: item.materialGroupNumber,
+ materialGroupDescription: item.materialGroupInfo || '',
+ displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo || ''}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialGroupNumber: material.materialGroupCode,
+ materialGroupInfo: material.materialGroupDescription
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재그룹명"
+ value={item.materialGroupInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialSelectorDialogSingle
+ triggerLabel={item.materialNumber || "자재 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialNumber ? {
+ materialCode: item.materialNumber,
+ materialName: item.materialInfo || '',
+ displayText: `${item.materialNumber} - ${item.materialInfo || ''}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialNumber: material.materialCode,
+ materialInfo: material.materialName
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialNumber: '',
+ materialInfo: ''
+ })
+ }
+ }}
+ title="자재 선택"
+ description="자재를 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재명"
+ value={item.materialInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Input
+ type="number"
+ min="0"
+ placeholder="수량"
+ value={item.quantity || ''}
+ onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
+ className="h-8 text-xs"
+ />
+ ) : (
+ <Input
+ type="number"
+ min="0"
+ placeholder="중량"
+ value={item.totalWeight || ''}
+ onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
+ className="h-8 text-xs"
+ />
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Select
+ value={item.quantityUnit || 'EA'}
+ onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EA">EA</SelectItem>
+ <SelectItem value="SET">SET</SelectItem>
+ <SelectItem value="LOT">LOT</SelectItem>
+ <SelectItem value="M">M</SelectItem>
+ <SelectItem value="M2">M²</SelectItem>
+ <SelectItem value="M3">M³</SelectItem>
+ </SelectContent>
+ </Select>
+ ) : (
+ <Select
+ value={item.weightUnit || 'KG'}
+ onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KG">KG</SelectItem>
+ <SelectItem value="TON">TON</SelectItem>
+ <SelectItem value="G">G</SelectItem>
+ <SelectItem value="LB">LB</SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="1"
+ step="1"
+ placeholder="구매단위"
+ value={item.purchaseUnit || ''}
+ onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정단가"
+ value={item.targetUnitPrice || ''}
+ onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정금액"
+ readOnly
+ value={item.targetAmount || ''}
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.targetCurrency || 'KRW'}
+ onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="예산금액"
+ value={item.budgetAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.budgetCurrency || 'KRW'}
+ onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="실적금액"
+ value={item.actualAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.actualCurrency || 'KRW'}
+ onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setSelectedItemForWbs(item.id)
+ setWbsCodeDialogOpen(true)
+ }}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.wbsCode ? (
+ <span className="truncate">
+ {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">WBS 코드 선택</span>
+ )}
+ </Button>
+ <WbsCodeSingleSelector
+ open={wbsCodeDialogOpen && selectedItemForWbs === item.id}
+ onOpenChange={(open) => {
+ setWbsCodeDialogOpen(open)
+ if (!open) setSelectedItemForWbs(null)
+ }}
+ selectedCode={item.wbsCode ? {
+ PROJ_NO: '',
+ WBS_ELMT: item.wbsCode,
+ WBS_ELMT_NM: item.wbsName || '',
+ WBS_LVL: ''
+ } : undefined}
+ onCodeSelect={(wbsCode) => {
+ updatePRItem(item.id, {
+ wbsCode: wbsCode.WBS_ELMT,
+ wbsName: wbsCode.WBS_ELMT_NM
+ })
+ setWbsCodeDialogOpen(false)
+ setSelectedItemForWbs(null)
+ }}
+ title="WBS 코드 선택"
+ description="WBS 코드를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="WBS명"
+ value={item.wbsName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setSelectedItemForCostCenter(item.id)
+ setCostCenterDialogOpen(true)
+ }}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.costCenterCode ? (
+ <span className="truncate">
+ {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">코스트센터 선택</span>
+ )}
+ </Button>
+ <CostCenterSingleSelector
+ open={costCenterDialogOpen && selectedItemForCostCenter === item.id}
+ onOpenChange={(open) => {
+ setCostCenterDialogOpen(open)
+ if (!open) setSelectedItemForCostCenter(null)
+ }}
+ selectedCode={item.costCenterCode ? {
+ KOSTL: item.costCenterCode,
+ KTEXT: '',
+ LTEXT: item.costCenterName || '',
+ DATAB: '',
+ DATBI: ''
+ } : undefined}
+ onCodeSelect={(costCenter) => {
+ updatePRItem(item.id, {
+ costCenterCode: costCenter.KOSTL,
+ costCenterName: costCenter.LTEXT
+ })
+ setCostCenterDialogOpen(false)
+ setSelectedItemForCostCenter(null)
+ }}
+ title="코스트센터 선택"
+ description="코스트센터를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="코스트센터명"
+ value={item.costCenterName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setSelectedItemForGlAccount(item.id)
+ setGlAccountDialogOpen(true)
+ }}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.glAccountCode ? (
+ <span className="truncate">
+ {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">GL계정 선택</span>
+ )}
+ </Button>
+ <GlAccountSingleSelector
+ open={glAccountDialogOpen && selectedItemForGlAccount === item.id}
+ onOpenChange={(open) => {
+ setGlAccountDialogOpen(open)
+ if (!open) setSelectedItemForGlAccount(null)
+ }}
+ selectedCode={item.glAccountCode ? {
+ SAKNR: item.glAccountCode,
+ FIPEX: '',
+ TEXT1: item.glAccountName || ''
+ } : undefined}
+ onCodeSelect={(glAccount) => {
+ updatePRItem(item.id, {
+ glAccountCode: glAccount.SAKNR,
+ glAccountName: glAccount.TEXT1
+ })
+ setGlAccountDialogOpen(false)
+ setSelectedItemForGlAccount(null)
+ }}
+ title="GL 계정 선택"
+ description="GL 계정을 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="GL계정명"
+ value={item.glAccountName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="date"
+ value={item.requestedDeliveryDate || ''}
+ onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="sticky right-0 z-10 bg-background border-l px-3 py-2">
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveItem(item.id)}
+ disabled={items.length <= 1}
+ className="h-7 w-7 p-0"
+ title="품목 삭제"
+ >
+ <Trash2 className="h-3.5 w-3.5" />
+ </Button>
+ </div>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <Package className="h-5 w-5" />
+ 입찰 품목 목록
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 입찰 대상 품목들을 관리합니다. 최소 하나의 아이템이 필요하며, 자재그룹코드는 필수입니다
+ </p>
+ <p className="text-xs text-amber-600 mt-1">
+ 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button onClick={() => setPreQuoteDialogOpen(true)} variant="outline" className="flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ 사전견적
+ </Button>
+ <Button onClick={handleAddItem} className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ 품목 추가
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 내정가 산정 기준 입력 폼 */}
+ <div className="space-y-2">
+ <Label htmlFor="targetPriceCalculationCriteria">내정가 산정 기준 (선택)</Label>
+ <Textarea
+ id="targetPriceCalculationCriteria"
+ placeholder="내정가 산정 기준을 입력하세요"
+ value={targetPriceCalculationCriteria}
+ onChange={(e) => setTargetPriceCalculationCriteria(e.target.value)}
+ rows={3}
+ className="resize-none"
+ />
+ <p className="text-xs text-muted-foreground">
+ 내정가를 산정한 기준이나 방법을 입력하세요
+ </p>
+ </div>
+ <div className="flex items-center space-x-4 p-4 bg-muted rounded-lg">
+ <div className="text-sm font-medium">계산 기준:</div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="quantity-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'quantity'}
+ onChange={() => handleQuantityWeightModeChange('quantity')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="quantity-mode" className="text-sm">수량 기준</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="weight-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'weight'}
+ onChange={() => handleQuantityWeightModeChange('weight')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="weight-mode" className="text-sm">중량 기준</label>
+ </div>
+ </div>
+ <div className="space-y-4">
+ {items.length > 0 ? (
+ renderPrItemsTable()
+ ) : (
+ <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg">
+ <Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <p className="text-gray-500 mb-2">아직 품목이 없습니다</p>
+ <p className="text-sm text-gray-400 mb-4">
+ 품목을 추가하여 입찰 세부내역을 작성하세요
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleAddItem}
+ className="flex items-center gap-2 mx-auto"
+ >
+ <Plus className="h-4 w-4" />
+ 첫 번째 품목 추가
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 액션 버튼 */}
+ <div className="flex justify-end gap-4">
+ <Button
+ onClick={handleSave}
+ disabled={isSubmitting}
+ className="min-w-[120px]"
+ >
+ {isSubmitting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
+
+ {/* 사전견적용 일반견적 생성 다이얼로그 */}
+ <CreatePreQuoteRfqDialog
+ open={preQuoteDialogOpen}
+ onOpenChange={setPreQuoteDialogOpen}
+ biddingId={biddingId}
+ biddingItems={items.map(item => ({
+ id: item.id,
+ materialGroupNumber: item.materialGroupNumber || undefined,
+ materialGroupInfo: item.materialGroupInfo || undefined,
+ materialNumber: item.materialNumber || undefined,
+ materialInfo: item.materialInfo || undefined,
+ quantity: item.quantity || undefined,
+ quantityUnit: item.quantityUnit || undefined,
+ totalWeight: item.totalWeight || undefined,
+ weightUnit: item.weightUnit || undefined,
+ }))}
+ picUserId={biddingPicUserId}
+ biddingConditions={biddingConditions}
+ onSuccess={() => {
+ toast.success('사전견적용 일반견적이 생성되었습니다')
+ }}
+ />
+
+ </div>
+ )
+}
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
new file mode 100644
index 00000000..d64c16c0
--- /dev/null
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -0,0 +1,661 @@
+'use client'
+
+import * as React from 'react'
+import { Calendar, Save, RefreshCw, Clock, Send } from 'lucide-react'
+import { updateBiddingSchedule, getBiddingById, getSpecificationMeetingDetailsAction } from '@/lib/bidding/service'
+import { useSession } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
+
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog'
+import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from '@/lib/bidding/pre-quote/service'
+import { registerBidding } from '@/lib/bidding/detail/service'
+import { useToast } from '@/hooks/use-toast'
+
+interface BiddingSchedule {
+ submissionStartDate?: string
+ submissionEndDate?: string
+ remarks?: string
+ isUrgent?: boolean
+ hasSpecificationMeeting?: boolean
+}
+
+interface SpecificationMeetingInfo {
+ meetingDate: string
+ meetingTime: string
+ location: string
+ address: string
+ contactPerson: string
+ contactPhone: string
+ contactEmail: string
+ agenda: string
+ materials: string
+ notes: string
+ isRequired: boolean
+}
+
+interface BiddingScheduleEditorProps {
+ biddingId: number
+}
+
+interface VendorContractRequirement {
+ vendorId: number
+ vendorName: string
+ vendorCode?: string | null
+ vendorCountry?: string
+ vendorEmail?: string | null
+ contactPerson?: string | null
+ contactEmail?: string | null
+ ndaYn?: boolean
+ generalGtcYn?: boolean
+ projectGtcYn?: boolean
+ agreementYn?: boolean
+ biddingCompanyId: number
+ biddingId: number
+}
+
+interface VendorWithContactInfo extends VendorContractRequirement {
+ contacts: Array<{
+ id: number
+ contactName: string
+ contactEmail: string
+ contactPhone?: string | null
+ contactPosition?: string | null
+ contactDepartment?: string | null
+ }>
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails: Array<{
+ id: string
+ email: string
+ name?: string
+ }>
+ hasExistingContracts: boolean
+}
+
+interface BiddingInvitationData {
+ vendors: VendorWithContactInfo[]
+ generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }>
+ message?: string
+}
+
+export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) {
+ const { data: session } = useSession()
+ const router = useRouter()
+ const { toast } = useToast()
+ const [schedule, setSchedule] = React.useState<BiddingSchedule>({})
+ const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ })
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [biddingInfo, setBiddingInfo] = React.useState<{ title: string; projectName?: string } | null>(null)
+ const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false)
+ const [selectedVendors, setSelectedVendors] = React.useState<VendorContractRequirement[]>([])
+
+ // 데이터 로딩
+ React.useEffect(() => {
+ const loadSchedule = async () => {
+ setIsLoading(true)
+ try {
+ const bidding = await getBiddingById(biddingId)
+ if (bidding) {
+ // 입찰 정보 저장
+ setBiddingInfo({
+ title: bidding.title || '',
+ projectName: bidding.projectName || undefined,
+ })
+
+ // 날짜를 문자열로 변환하는 헬퍼
+ const formatDateTime = (date: unknown): string => {
+ if (!date) return ''
+ if (typeof date === 'string') {
+ // 이미 datetime-local 형식인 경우
+ if (date.includes('T')) {
+ return date.slice(0, 16)
+ }
+ return date
+ }
+ if (date instanceof Date) return date.toISOString().slice(0, 16)
+ return ''
+ }
+
+ setSchedule({
+ submissionStartDate: formatDateTime(bidding.submissionStartDate),
+ submissionEndDate: formatDateTime(bidding.submissionEndDate),
+ remarks: bidding.remarks || '',
+ isUrgent: bidding.isUrgent || false,
+ hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
+ })
+
+ // 사양설명회 정보 로드
+ if (bidding.hasSpecificationMeeting) {
+ try {
+ const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
+ if (meetingDetails.success && meetingDetails.data) {
+ const meeting = meetingDetails.data
+ setSpecMeetingInfo({
+ meetingDate: meeting.meetingDate ? new Date(meeting.meetingDate).toISOString().slice(0, 16) : '',
+ meetingTime: meeting.meetingTime || '',
+ location: meeting.location || '',
+ address: meeting.address || '',
+ contactPerson: meeting.contactPerson || '',
+ contactPhone: meeting.contactPhone || '',
+ contactEmail: meeting.contactEmail || '',
+ agenda: meeting.agenda || '',
+ materials: meeting.materials || '',
+ notes: meeting.notes || '',
+ isRequired: meeting.isRequired || false,
+ })
+ }
+ } catch (error) {
+ console.error('Failed to load specification meeting details:', error)
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load schedule:', error)
+ toast({
+ title: '오류',
+ description: '일정 정보를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadSchedule()
+ }, [biddingId, toast])
+
+ // 선정된 업체들 조회
+ const getSelectedVendors = React.useCallback(async (): Promise<VendorContractRequirement[]> => {
+ try {
+ const result = await getSelectedVendorsForBidding(biddingId)
+ if (result.success) {
+ // 타입 변환: null을 undefined로 변환
+ return result.vendors.map((vendor): VendorContractRequirement => ({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode ?? undefined,
+ vendorCountry: vendor.vendorCountry,
+ vendorEmail: vendor.vendorEmail ?? undefined,
+ contactPerson: vendor.contactPerson ?? undefined,
+ contactEmail: vendor.contactEmail ?? undefined,
+ ndaYn: vendor.ndaYn,
+ generalGtcYn: vendor.generalGtcYn,
+ projectGtcYn: vendor.projectGtcYn,
+ agreementYn: vendor.agreementYn,
+ biddingCompanyId: vendor.biddingCompanyId,
+ biddingId: vendor.biddingId,
+ }))
+ } else {
+ console.error('선정된 업체 조회 실패:', 'error' in result ? result.error : '알 수 없는 오류')
+ return []
+ }
+ } catch (error) {
+ console.error('선정된 업체 조회 실패:', error)
+ return []
+ }
+ }, [biddingId])
+
+ // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
+ React.useEffect(() => {
+ if (isBiddingInvitationDialogOpen) {
+ getSelectedVendors().then(vendors => {
+ setSelectedVendors(vendors)
+ })
+ }
+ }, [isBiddingInvitationDialogOpen, getSelectedVendors])
+
+ // 입찰 초대 발송 핸들러
+ const handleBiddingInvitationSend = async (data: BiddingInvitationData) => {
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+
+ // 1. 기본계약 발송
+ // sendBiddingBasicContracts에 필요한 형식으로 변환
+ const vendorDataForContract = data.vendors.map(vendor => ({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode || undefined,
+ vendorCountry: vendor.vendorCountry,
+ selectedMainEmail: vendor.selectedMainEmail,
+ additionalEmails: vendor.additionalEmails,
+ customEmails: vendor.customEmails,
+ contractRequirements: {
+ ndaYn: vendor.ndaYn || false,
+ generalGtcYn: vendor.generalGtcYn || false,
+ projectGtcYn: vendor.projectGtcYn || false,
+ agreementYn: vendor.agreementYn || false,
+ },
+ biddingCompanyId: vendor.biddingCompanyId,
+ biddingId: vendor.biddingId,
+ hasExistingContracts: vendor.hasExistingContracts,
+ }))
+
+ const contractResult = await sendBiddingBasicContracts(
+ biddingId,
+ vendorDataForContract,
+ data.generatedPdfs,
+ data.message
+ )
+
+ if (!contractResult.success) {
+ const errorMessage = 'message' in contractResult
+ ? contractResult.message
+ : 'error' in contractResult
+ ? contractResult.error
+ : '기본계약 발송에 실패했습니다.'
+ toast({
+ title: '기본계약 발송 실패',
+ description: errorMessage,
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 2. 입찰 등록 진행
+ const registerResult = await registerBidding(biddingId, userId)
+
+ if (registerResult.success) {
+ toast({
+ title: '본입찰 초대 완료',
+ description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
+ })
+ setIsBiddingInvitationDialogOpen(false)
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: 'error' in registerResult ? registerResult.error : '입찰 등록에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('본입찰 초대 실패:', error)
+ toast({
+ title: '오류',
+ description: '본입찰 초대에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ const handleSave = async () => {
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+
+ // 사양설명회 정보 유효성 검사
+ if (schedule.hasSpecificationMeeting) {
+ if (!specMeetingInfo.meetingDate || !specMeetingInfo.location || !specMeetingInfo.contactPerson) {
+ toast({
+ title: '오류',
+ description: '사양설명회 필수 정보가 누락되었습니다. (회의일시, 장소, 담당자)',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ }
+
+ const result = await updateBiddingSchedule(
+ biddingId,
+ schedule,
+ userId,
+ schedule.hasSpecificationMeeting ? specMeetingInfo : undefined
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: '일정 정보가 성공적으로 저장되었습니다.',
+ })
+ } else {
+ toast({
+ title: '오류',
+ description: 'error' in result ? result.error : '일정 정보 저장에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('Failed to save schedule:', error)
+ toast({
+ title: '오류',
+ description: '일정 정보 저장에 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => {
+ setSchedule(prev => ({ ...prev, [field]: value }))
+
+ // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화
+ if (field === 'hasSpecificationMeeting' && value === false) {
+ setSpecMeetingInfo({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ })
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">일정 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Calendar className="h-5 w-5" />
+ 입찰 일정 관리
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 입찰의 주요 일정들을 설정하고 관리합니다.
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 입찰서 제출 기간 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium flex items-center gap-2">
+ <Clock className="h-4 w-4" />
+ 입찰서 제출 기간
+ </h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="submission-start">제출 시작일시</Label>
+ <Input
+ id="submission-start"
+ type="datetime-local"
+ value={schedule.submissionStartDate || ''}
+ onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="submission-end">제출 마감일시</Label>
+ <Input
+ id="submission-end"
+ type="datetime-local"
+ value={schedule.submissionEndDate || ''}
+ onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 긴급 여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">긴급여부</Label>
+ <p className="text-sm text-muted-foreground">
+ 긴급 입찰로 표시할 경우 활성화하세요
+ </p>
+ </div>
+ <Switch
+ checked={schedule.isUrgent || false}
+ onCheckedChange={(checked) => handleScheduleChange('isUrgent', checked)}
+ />
+ </div>
+
+ {/* 사양설명회 실시 여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">사양설명회 실시</Label>
+ <p className="text-sm text-muted-foreground">
+ 사양설명회를 실시할 경우 상세 정보를 입력하세요
+ </p>
+ </div>
+ <Switch
+ checked={schedule.hasSpecificationMeeting || false}
+ onCheckedChange={(checked) => handleScheduleChange('hasSpecificationMeeting', checked)}
+ />
+ </div>
+
+ {/* 사양설명회 상세 정보 */}
+ {schedule.hasSpecificationMeeting && (
+ <div className="space-y-6 p-4 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label>회의일시 <span className="text-red-500">*</span></Label>
+ <Input
+ type="datetime-local"
+ value={specMeetingInfo.meetingDate}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingDate: e.target.value }))}
+ className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.meetingDate && (
+ <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <Label>회의시간</Label>
+ <Input
+ placeholder="예: 14:00 ~ 16:00"
+ value={specMeetingInfo.meetingTime}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))}
+ />
+ </div>
+ </div>
+ <div>
+ <Label>장소 <span className="text-red-500">*</span></Label>
+ <Input
+ placeholder="회의 장소"
+ value={specMeetingInfo.location}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, location: e.target.value }))}
+ className={!specMeetingInfo.location ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.location && (
+ <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <Label>주소</Label>
+ <Input
+ placeholder="회의 장소 주소"
+ value={specMeetingInfo.address}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, address: e.target.value }))}
+ />
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div>
+ <Label>담당자 <span className="text-red-500">*</span></Label>
+ <Input
+ placeholder="담당자명"
+ value={specMeetingInfo.contactPerson}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPerson: e.target.value }))}
+ className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''}
+ />
+ {!specMeetingInfo.contactPerson && (
+ <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <Label>연락처</Label>
+ <Input
+ placeholder="전화번호"
+ value={specMeetingInfo.contactPhone}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))}
+ />
+ </div>
+ <div>
+ <Label>이메일</Label>
+ <Input
+ type="email"
+ placeholder="이메일"
+ value={specMeetingInfo.contactEmail}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))}
+ />
+ </div>
+ </div>
+ <div>
+ <Label>안건</Label>
+ <Textarea
+ placeholder="회의 안건을 입력하세요"
+ value={specMeetingInfo.agenda}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, agenda: e.target.value }))}
+ rows={3}
+ />
+ </div>
+ <div>
+ <Label>자료</Label>
+ <Textarea
+ placeholder="회의 자료 정보를 입력하세요"
+ value={specMeetingInfo.materials}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, materials: e.target.value }))}
+ rows={3}
+ />
+ </div>
+ <div>
+ <Label>비고</Label>
+ <Textarea
+ placeholder="추가 사항을 입력하세요"
+ value={specMeetingInfo.notes}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, notes: e.target.value }))}
+ rows={3}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 비고 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">비고</h3>
+ <div className="space-y-2">
+ <Label htmlFor="remarks">추가 사항</Label>
+ <Textarea
+ id="remarks"
+ value={schedule.remarks || ''}
+ onChange={(e) => handleScheduleChange('remarks', e.target.value)}
+ placeholder="일정에 대한 추가 설명이나 참고사항을 입력하세요"
+ rows={4}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 일정 요약 카드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>일정 요약</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2 text-sm">
+ <div className="flex justify-between">
+ <span className="font-medium">입찰서 제출 기간:</span>
+ <span>
+ {schedule.submissionStartDate && schedule.submissionEndDate
+ ? `${new Date(schedule.submissionStartDate).toLocaleString('ko-KR')} ~ ${new Date(schedule.submissionEndDate).toLocaleString('ko-KR')}`
+ : '미설정'
+ }
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="font-medium">긴급여부:</span>
+ <span>
+ {schedule.isUrgent ? '예' : '아니오'}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="font-medium">사양설명회 실시:</span>
+ <span>
+ {schedule.hasSpecificationMeeting ? '예' : '아니오'}
+ </span>
+ </div>
+ {schedule.hasSpecificationMeeting && specMeetingInfo.meetingDate && (
+ <div className="flex justify-between">
+ <span className="font-medium">사양설명회 일시:</span>
+ <span>
+ {new Date(specMeetingInfo.meetingDate).toLocaleString('ko-KR')}
+ </span>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 액션 버튼 */}
+ <div className="flex justify-between gap-4">
+ <Button
+ variant="default"
+ onClick={() => setIsBiddingInvitationDialogOpen(true)}
+ disabled={!biddingInfo}
+ className="min-w-[120px]"
+ >
+ <Send className="w-4 h-4 mr-2" />
+ 입찰공고
+ </Button>
+ <div className="flex gap-4">
+ <Button
+ onClick={handleSave}
+ disabled={isSubmitting}
+ className="min-w-[120px]"
+ >
+ {isSubmitting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+
+ {/* 입찰 초대 다이얼로그 */}
+ {biddingInfo && (
+ <BiddingInvitationDialog
+ open={isBiddingInvitationDialogOpen}
+ onOpenChange={setIsBiddingInvitationDialogOpen}
+ vendors={selectedVendors}
+ biddingId={biddingId}
+ biddingTitle={biddingInfo.title}
+ onSend={handleBiddingInvitationSend}
+ />
+ )}
+ </div>
+ )
+}
+
diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
new file mode 100644
index 00000000..88732deb
--- /dev/null
+++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
@@ -0,0 +1,742 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { format } from "date-fns"
+import { CalendarIcon, Loader2, Trash2, PlusCircle } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Calendar } from "@/components/ui/calendar"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { createPreQuoteRfqAction, previewGeneralRfqCode } from "@/lib/bidding/service"
+import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
+import { MaterialSearchItem } from "@/lib/material/material-group-service"
+import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
+import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
+import { ProcurementManagerSelector } from "@/components/common/selectors/procurement-manager"
+import type { ProcurementManagerWithUser } from "@/components/common/selectors/procurement-manager/procurement-manager-service"
+
+// 아이템 스키마
+const itemSchema = z.object({
+ itemCode: z.string().optional(),
+ itemName: z.string().optional(),
+ materialCode: z.string().optional(),
+ materialName: z.string().optional(),
+ quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
+ uom: z.string().min(1, "단위를 입력해주세요"),
+ remark: z.string().optional(),
+})
+
+// 사전견적용 일반견적 생성 폼 스키마
+const createPreQuoteRfqSchema = z.object({
+ rfqType: z.string().min(1, "견적 종류를 선택해주세요"),
+ rfqTitle: z.string().min(1, "견적명을 입력해주세요"),
+ dueDate: z.date({
+ required_error: "제출마감일을 선택해주세요",
+ }),
+ picUserId: z.number().optional(),
+ projectId: z.number().optional(),
+ remark: z.string().optional(),
+ items: z.array(itemSchema).min(1, "최소 하나의 자재를 추가해주세요"),
+})
+
+type CreatePreQuoteRfqFormValues = z.infer<typeof createPreQuoteRfqSchema>
+
+interface CreatePreQuoteRfqDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ biddingId: number
+ biddingItems: Array<{
+ id: number
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ materialNumber?: string | null
+ materialInfo?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ }>
+ biddingConditions?: {
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ } | null
+ onSuccess?: () => void
+}
+
+export function CreatePreQuoteRfqDialog({
+ open,
+ onOpenChange,
+ biddingId,
+ biddingItems,
+ biddingConditions,
+ onSuccess
+}: CreatePreQuoteRfqDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [previewCode, setPreviewCode] = React.useState("")
+ const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
+ const [selectedManager, setSelectedManager] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+ const { data: session } = useSession()
+
+ const userId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null;
+ }, [session]);
+
+ // 입찰품목을 일반견적 아이템으로 매핑
+ const initialItems = React.useMemo(() => {
+ return biddingItems.map((item) => ({
+ itemCode: item.materialGroupNumber || "",
+ itemName: item.materialGroupInfo || "",
+ materialCode: item.materialNumber || "",
+ materialName: item.materialInfo || "",
+ quantity: item.quantity ? parseFloat(item.quantity) : 1,
+ uom: item.quantityUnit || item.weightUnit || "EA",
+ remark: "",
+ }))
+ }, [biddingItems])
+
+ const form = useForm<CreatePreQuoteRfqFormValues>({
+ resolver: zodResolver(createPreQuoteRfqSchema),
+ defaultValues: {
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ })
+
+ // 다이얼로그가 열릴 때 폼 초기화
+ React.useEffect(() => {
+ if (open) {
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedManager(undefined)
+ setPreviewCode("")
+ }
+ }, [open, initialItems, form])
+
+ // 견적담당자 선택 시 RFQ 코드 미리보기 생성
+ React.useEffect(() => {
+ if (!selectedManager?.user?.id) {
+ setPreviewCode("")
+ return
+ }
+
+ // 즉시 실행 함수 패턴 사용
+ (async () => {
+ setIsLoadingPreview(true)
+ try {
+ const code = await previewGeneralRfqCode(selectedManager.user!.id!)
+ setPreviewCode(code)
+ } catch (error) {
+ console.error("코드 미리보기 오류:", error)
+ setPreviewCode("")
+ } finally {
+ setIsLoadingPreview(false)
+ }
+ })()
+ }, [selectedManager])
+
+ // 견적 종류 변경
+ const handleRfqTypeChange = (value: string) => {
+ form.setValue("rfqType", value)
+ }
+
+ const handleCancel = () => {
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedManager(undefined)
+ setPreviewCode("")
+ onOpenChange(false)
+ }
+
+ const onSubmit = async (data: CreatePreQuoteRfqFormValues) => {
+ if (!userId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ if (!selectedManager?.user?.id) {
+ toast.error("견적담당자를 선택해주세요")
+ return
+ }
+
+ const picUserId = selectedManager.user.id
+
+ setIsLoading(true)
+
+ try {
+ // 서버 액션 호출 (입찰 조건 포함)
+ const result = await createPreQuoteRfqAction({
+ biddingId,
+ rfqType: data.rfqType,
+ rfqTitle: data.rfqTitle,
+ dueDate: data.dueDate,
+ picUserId,
+ projectId: data.projectId,
+ remark: data.remark || "",
+ items: data.items as Array<{
+ itemCode: string;
+ itemName: string;
+ materialCode?: string;
+ materialName?: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ }>,
+ biddingConditions: biddingConditions || undefined,
+ createdBy: userId,
+ updatedBy: userId,
+ })
+
+ if (result.success) {
+ toast.success(result.message, {
+ description: result.data?.rfqCode ? `RFQ 코드: ${result.data.rfqCode}` : undefined,
+ })
+
+ // 다이얼로그 닫기
+ onOpenChange(false)
+
+ // 성공 콜백 실행
+ if (onSuccess) {
+ onSuccess()
+ }
+ } else {
+ toast.error(result.error || "사전견적용 일반견적 생성에 실패했습니다")
+ }
+
+ } catch (error) {
+ console.error('사전견적용 일반견적 생성 오류:', error)
+ toast.error("사전견적용 일반견적 생성에 실패했습니다", {
+ description: "알 수 없는 오류가 발생했습니다",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 아이템 추가
+ const handleAddItem = () => {
+ append({
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col">
+ {/* 고정된 헤더 */}
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>사전견적용 일반견적 생성</DialogTitle>
+ <DialogDescription>
+ 입찰의 사전견적을 위한 일반견적을 생성합니다. 입찰품목이 자재정보로 매핑되어 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <ScrollArea className="flex-1 px-1">
+ <Form {...form}>
+ <form id="createPreQuoteRfqForm" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2">
+
+ {/* 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ {/* 견적 종류 */}
+ <div className="space-y-2">
+ <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적 종류 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={handleRfqTypeChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="견적 종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="단가계약">단가계약</SelectItem>
+ <SelectItem value="매각계약">매각계약</SelectItem>
+ <SelectItem value="일반계약">일반계약</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 제출마감일 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 제출마감일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy-MM-dd")
+ ) : (
+ <span>제출마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 견적명 */}
+ <FormField
+ control={form.control}
+ name="rfqTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 견적명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 입찰 사전견적용 일반견적"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 견적의 목적이나 내용을 간단명료하게 입력해주세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>프로젝트</FormLabel>
+ <FormControl>
+ {/* ProjectSelector는 별도 컴포넌트 필요 */}
+ <Input
+ placeholder="프로젝트 ID (선택사항)"
+ type="number"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 담당자 정보 */}
+ <FormField
+ control={form.control}
+ name="picUserId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적담당자 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <ProcurementManagerSelector
+ selectedManager={selectedManager}
+ onManagerSelect={(manager) => {
+ setSelectedManager(manager)
+ field.onChange(manager.user?.id)
+ }}
+ placeholder="견적담당자를 선택하세요"
+ />
+ </FormControl>
+ <FormDescription>
+ 사전견적용 일반견적의 담당자를 선택합니다
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* RFQ 코드 미리보기 */}
+ {previewCode && (
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary" className="font-mono text-sm">
+ 예상 RFQ 코드: {previewCode}
+ </Badge>
+ {isLoadingPreview && (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ )}
+ </div>
+ )}
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 비고사항을 입력하세요"
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 아이템 정보 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">자재 정보</h3>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleAddItem}
+ >
+ <PlusCircle className="mr-2 h-4 w-4" />
+ 자재 추가
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ {fields.map((field, index) => (
+ <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50">
+ <div className="flex items-center justify-between mb-3">
+ <span className="text-sm font-medium text-gray-700">
+ 자재 #{index + 1}
+ </span>
+ {fields.length > 1 && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => remove(index)}
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+
+ {/* 자재그룹 선택 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재그룹(자재그룹명) <span className="text-red-500">*</span>
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel="자재그룹 선택"
+ selectedMaterial={(() => {
+ const itemCode = form.watch(`items.${index}.itemCode`);
+ const itemName = form.watch(`items.${index}.itemName`);
+ if (itemCode && itemName) {
+ return {
+ materialGroupCode: itemCode,
+ materialGroupDescription: itemName,
+ displayText: `${itemCode} - ${itemName}`
+ } as MaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.itemCode`, material?.materialGroupCode || '');
+ form.setValue(`items.${index}.itemName`, material?.materialGroupDescription || '');
+ }}
+ placeholder="자재그룹을 검색하세요..."
+ title="자재그룹 선택"
+ description="원하는 자재그룹을 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ {/* 자재코드 선택 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재코드(자재명)
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialSelectorDialogSingle
+ triggerLabel="자재코드 선택"
+ selectedMaterial={(() => {
+ const materialCode = form.watch(`items.${index}.materialCode`);
+ const materialName = form.watch(`items.${index}.materialName`);
+ if (materialCode && materialName) {
+ return {
+ materialCode: materialCode,
+ materialName: materialName,
+ displayText: `${materialCode} - ${materialName}`
+ } as SAPMaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.materialCode`, material?.materialCode || '');
+ form.setValue(`items.${index}.materialName`, material?.materialName || '');
+ }}
+ placeholder="자재코드를 검색하세요..."
+ title="자재코드 선택"
+ description="원하는 자재코드를 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3">
+ {/* 수량 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 수량 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ placeholder="1"
+ className="h-8 text-sm"
+ {...field}
+ onChange={(e) => field.onChange(Number(e.target.value))}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단위 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.uom`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 단위 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger className="h-8 text-sm">
+ <SelectValue placeholder="단위 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="EA">EA (Each)</SelectItem>
+ <SelectItem value="KG">KG (Kilogram)</SelectItem>
+ <SelectItem value="M">M (Meter)</SelectItem>
+ <SelectItem value="L">L (Liter)</SelectItem>
+ <SelectItem value="PC">PC (Piece)</SelectItem>
+ <SelectItem value="BOX">BOX (Box)</SelectItem>
+ <SelectItem value="SET">SET (Set)</SelectItem>
+ <SelectItem value="LOT">LOT (Lot)</SelectItem>
+ <SelectItem value="PCS">PCS (Pieces)</SelectItem>
+ <SelectItem value="TON">TON (Ton)</SelectItem>
+ <SelectItem value="G">G (Gram)</SelectItem>
+ <SelectItem value="ML">ML (Milliliter)</SelectItem>
+ <SelectItem value="CM">CM (Centimeter)</SelectItem>
+ <SelectItem value="MM">MM (Millimeter)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 비고 */}
+ <div className="mt-3">
+ <FormField
+ control={form.control}
+ name={`items.${index}.remark`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">비고</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="자재별 비고사항"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </form>
+ </Form>
+ </ScrollArea>
+
+ {/* 고정된 푸터 */}
+ <DialogFooter className="flex-shrink-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="createPreQuoteRfqForm"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "생성 중..." : "사전견적용 일반견적 생성"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+