diff options
Diffstat (limited to 'lib/bidding/vendor')
| -rw-r--r-- | lib/bidding/vendor/components/pr-items-pricing-table.tsx | 27 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-detail.tsx | 904 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list-columns.tsx | 104 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-pre-quote.tsx | 1413 |
4 files changed, 879 insertions, 1569 deletions
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx index 483bce5c..efa10af2 100644 --- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx +++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx @@ -32,16 +32,27 @@ import { interface PrItem { id: number + biddingId: number itemNumber: string | null - prNumber: string | null + projectId: number | null + projectInfo: string | null itemInfo: string | null - materialDescription: string | null + shi: string | null + materialGroupNumber: string | null + materialGroupInfo: string | null + materialNumber: string | null + materialInfo: string | null + requestedDeliveryDate: Date | null + annualUnitPrice: string | null + currency: string | null quantity: string | null quantityUnit: string | null totalWeight: string | null weightUnit: string | null - currency: string | null - requestedDeliveryDate: string | null + priceUnit: string | null + purchaseUnit: string | null + materialWeight: string | null + prNumber: string | null hasSpecDocument: boolean | null } @@ -283,8 +294,10 @@ export function PrItemsPricingTable({ <TableHead>자재내역</TableHead> <TableHead>수량</TableHead> <TableHead>단위</TableHead> + <TableHead>구매단위</TableHead> <TableHead>중량</TableHead> <TableHead>중량단위</TableHead> + <TableHead>가격단위</TableHead> <TableHead>SHI 납품요청일</TableHead> <TableHead>견적단가</TableHead> <TableHead>견적금액</TableHead> @@ -315,18 +328,20 @@ export function PrItemsPricingTable({ </div> </TableCell> <TableCell> - <div className="max-w-32 truncate" title={item.materialDescription || ''}> - {item.materialDescription || '-'} + <div className="max-w-32 truncate" title={item.materialInfo || ''}> + {item.materialInfo || '-'} </div> </TableCell> <TableCell className="text-right"> {item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'} </TableCell> <TableCell>{item.quantityUnit || '-'}</TableCell> + <TableCell>{item.purchaseUnit || '-'}</TableCell> <TableCell className="text-right"> {item.totalWeight ? parseFloat(item.totalWeight).toLocaleString() : '-'} </TableCell> <TableCell>{item.weightUnit || '-'}</TableCell> + <TableCell>{item.priceUnit || '-'}</TableCell> <TableCell> {item.requestedDeliveryDate ? formatDate(item.requestedDeliveryDate, 'KR') : '-' diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx index f9241f7b..273c0667 100644 --- a/lib/bidding/vendor/partners-bidding-detail.tsx +++ b/lib/bidding/vendor/partners-bidding-detail.tsx @@ -1,11 +1,16 @@ 'use client' import * as React from 'react' + +// 브라우저 환경 체크 +const isBrowser = typeof window !== 'undefined' import { useRouter } from 'next/navigation' +import { useTransition } from 'react' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Label } from '@/components/ui/label' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { ArrowLeft, User, @@ -15,8 +20,10 @@ import { XCircle, Save, FileText, - Building2, - Package + Package, + Trash2, + Calendar, + ChevronDown } from 'lucide-react' import { formatDate } from '@/lib/utils' @@ -24,19 +31,26 @@ import { getBiddingDetailsForPartners, submitPartnerResponse, updatePartnerBiddingParticipation, - saveBiddingDraft + saveBiddingDraft, + getPriceAdjustmentFormByBiddingCompanyId } from '../detail/service' +import { cancelBiddingResponse } from '../detail/bidding-actions' import { getPrItemsForBidding, getSavedPrItemQuotations } from '@/lib/bidding/pre-quote/service' +import { getBiddingConditions } from '@/lib/bidding/service' import { PrItemsPricingTable } from './components/pr-items-pricing-table' import { SimpleFileUpload } from './components/simple-file-upload' +import { getTaxConditionName } from "@/lib/tax-conditions/types" import { biddingStatusLabels, contractTypeLabels, biddingTypeLabels } from '@/db/schema' import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' import { useSession } from 'next-auth/react' +import { getBiddingNotice } from '../service' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' interface PartnersBiddingDetailProps { biddingId: number @@ -51,7 +65,6 @@ interface BiddingDetail { itemName: string | null title: string description: string | null - content: string | null contractType: string biddingType: string awardCount: string | null @@ -66,33 +79,46 @@ interface BiddingDetail { budget: number | null targetPrice: number | null status: string - managerName: string | null - managerEmail: string | null - managerPhone: string | null + bidPicName: string | null // 입찰담당자 + supplyPicName: string | null // 조달담당자 biddingCompanyId: number biddingId: number invitationStatus: string finalQuoteAmount: number | null finalQuoteSubmittedAt: Date | null + isFinalSubmission: boolean isWinner: boolean isAttendingMeeting: boolean | null isBiddingParticipated: boolean | null additionalProposals: string | null responseSubmittedAt: Date | null + priceAdjustmentResponse: boolean | null // 연동제 적용 여부 + isPreQuoteParticipated: boolean | null // 사전견적 참여 여부 } interface PrItem { id: number + biddingId: number itemNumber: string | null - prNumber: string | null + projectId: number | null + projectInfo: string | null itemInfo: string | null - materialDescription: string | null + shi: string | null + materialGroupNumber: string | null + materialGroupInfo: string | null + materialNumber: string | null + materialInfo: string | null + requestedDeliveryDate: Date | null + annualUnitPrice: string | null + currency: string | null quantity: string | null quantityUnit: string | null totalWeight: string | null weightUnit: string | null - currency: string | null - requestedDeliveryDate: string | null + priceUnit: string | null + purchaseUnit: string | null + materialWeight: string | null + prNumber: string | null hasSpecDocument: boolean | null } @@ -104,6 +130,22 @@ interface BiddingPrItemQuotation { technicalSpecification?: string } +interface BiddingConditions { + id?: number + biddingId?: number + paymentTerms?: string + taxConditions?: string + incoterms?: string + incotermsOption?: string + contractDeliveryDate?: string + shippingPort?: string + destinationPort?: string + isPriceAdjustmentApplicable?: boolean + sparePartOptions?: string + createdAt?: string + updatedAt?: string +} + export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingDetailProps) { const router = useRouter() const { toast } = useToast() @@ -114,7 +156,23 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD const [isUpdatingParticipation, setIsUpdatingParticipation] = React.useState(false) const [isSavingDraft, setIsSavingDraft] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) - + const [isCancelling, setIsCancelling] = React.useState(false) + const [isFinalSubmission, setIsFinalSubmission] = React.useState(false) + const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false) + + // 입찰공고 관련 상태 + const [biddingNotice, setBiddingNotice] = React.useState<{ + id?: number + biddingId?: number + title?: string + content?: string + isTemplate?: boolean + createdAt?: string + updatedAt?: string + } | null>(null) + const [biddingConditions, setBiddingConditions] = React.useState<BiddingConditions | null>(null) + const [isNoticeOpen, setIsNoticeOpen] = React.useState(false) + // 품목별 견적 관련 상태 const [prItems, setPrItems] = React.useState<PrItem[]>([]) const [prItemQuotations, setPrItemQuotations] = React.useState<BiddingPrItemQuotation[]>([]) @@ -125,21 +183,95 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD finalQuoteAmount: '', proposedContractDeliveryDate: '', additionalProposals: '', + priceAdjustmentResponse: null as boolean | null, // null: 미선택, true: 적용, false: 미적용 + }) + + // 연동제 폼 상태 + const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({ + itemName: '', + adjustmentReflectionPoint: '', + majorApplicableRawMaterial: '', + adjustmentFormula: '', + rawMaterialPriceIndex: '', + referenceDate: '', + comparisonDate: '', + adjustmentRatio: '', + notes: '', + adjustmentConditions: '', + majorNonApplicableRawMaterial: '', + adjustmentPeriod: '', + contractorWriter: '', + adjustmentDate: '', + nonApplicableReason: '', // 연동제 미희망 사유 }) const userId = session.data?.user?.id || '' // 데이터 로드 + // 입찰공고 로드 함수 + const loadBiddingNotice = React.useCallback(async () => { + setIsBiddingNoticeLoading(true) + try { + const notice = await getBiddingNotice(biddingId) + console.log('입찰공고 로드 성공:', notice) + setBiddingNotice(notice) + } catch (error) { + console.error('Failed to load bidding notice:', error) + } finally { + setIsBiddingNoticeLoading(false) + } + }, [biddingId]) + React.useEffect(() => { const loadData = async () => { try { setIsLoading(true) - const [result, prItemsResult] = await Promise.all([ - getBiddingDetailsForPartners(biddingId, companyId), - getPrItemsForBidding(biddingId) + // 데이터 로드 시작 로그 + console.log('입찰 상세 데이터 로드 시작:', { biddingId, companyId }) + + console.log('데이터베이스 쿼리 시작...') + + const [result, prItemsResult, noticeResult, conditionsResult] = await Promise.all([ + getBiddingDetailsForPartners(biddingId, companyId).catch(error => { + console.error('Failed to get bidding details:', error) + return null + }), + getPrItemsForBidding(biddingId).catch(error => { + console.error('Failed to get PR items:', error) + return [] + }), + loadBiddingNotice().catch(error => { + console.error('Failed to load bidding notice:', error) + return null + }), + getBiddingConditions(biddingId).catch(error => { + console.error('Failed to load bidding conditions:', error) + return null + }) ]) - + + console.log('데이터베이스 쿼리 완료:', { + resultExists: !!result, + prItemsExists: !!prItemsResult, + noticeExists: !!noticeResult, + conditionsExists: !!conditionsResult + }) + + console.log('데이터 로드 완료:', { + result: !!result, + prItemsCount: Array.isArray(prItemsResult) ? prItemsResult.length : 0, + noticeResult: !!noticeResult, + conditionsResult: !!conditionsResult + }) + if (result) { + console.log('입찰 상세 데이터 로드 성공:', { + biddingId: result.biddingId, + isBiddingParticipated: result.isBiddingParticipated, + invitationStatus: result.invitationStatus, + finalQuoteAmount: result.finalQuoteAmount + }) + setBiddingDetail(result) // 기존 응답 데이터로 폼 초기화 @@ -147,7 +279,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD finalQuoteAmount: result.finalQuoteAmount?.toString() || '', proposedContractDeliveryDate: result.proposedContractDeliveryDate || '', additionalProposals: result.additionalProposals || '', + priceAdjustmentResponse: result.priceAdjustmentResponse || null, }) + + // 입찰 조건 로드 + if (conditionsResult) { + console.log('입찰 조건 로드:', conditionsResult) + setBiddingConditions(conditionsResult) + } } // PR 아이템 설정 @@ -158,29 +297,70 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD try { // 사전견적 데이터를 가져와서 본입찰용으로 변환 const preQuoteData = await getSavedPrItemQuotations(result.biddingCompanyId) - - // 사전견적 데이터를 본입찰 포맷으로 변환 - const convertedQuotations = preQuoteData.map(item => ({ - prItemId: item.prItemId, - bidUnitPrice: item.bidUnitPrice, - bidAmount: item.bidAmount, - proposedDeliveryDate: item.proposedDeliveryDate || undefined, - technicalSpecification: item.technicalSpecification || undefined - })) - - setPrItemQuotations(convertedQuotations) - - // 총 금액 계산 - const total = convertedQuotations.reduce((sum, q) => sum + q.bidAmount, 0) - setTotalQuotationAmount(total) + + if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) { + console.log('사전견적 데이터:', preQuoteData) + + // 사전견적 데이터를 본입찰 포맷으로 변환 + const convertedQuotations = preQuoteData + .filter(item => item && typeof item === 'object' && item.prItemId) + .map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice, + bidAmount: item.bidAmount, + proposedDeliveryDate: item.proposedDeliveryDate || undefined, + technicalSpecification: item.technicalSpecification || undefined + })) + + console.log('변환된 견적 데이터:', convertedQuotations) + + if (Array.isArray(convertedQuotations) && convertedQuotations.length > 0) { + setPrItemQuotations(convertedQuotations) + + // 총 금액 계산 + const total = convertedQuotations.reduce((sum, q) => { + const amount = Number(q.bidAmount) || 0 + return sum + amount + }, 0) + setTotalQuotationAmount(total) + console.log('계산된 총 금액:', total) + } + } // 응찰 확정 시에만 사전견적 금액을 finalQuoteAmount로 설정 - if (totalQuotationAmount > 0 && result.isBiddingParticipated === true) { + if (totalQuotationAmount > 0 && result?.isBiddingParticipated === true) { + console.log('응찰 확정됨, 사전견적 금액 설정:', totalQuotationAmount) + console.log('사전견적 금액을 finalQuoteAmount로 설정:', totalQuotationAmount) setResponseData(prev => ({ ...prev, finalQuoteAmount: totalQuotationAmount.toString() })) } + + // 연동제 데이터 로드 (사전견적에서 답변했으면 로드, 아니면 입찰 조건 확인) + if (result.priceAdjustmentResponse !== null) { + // 사전견적에서 이미 답변한 경우 - 연동제 폼 로드 + const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(result.biddingCompanyId) + if (savedPriceAdjustmentForm) { + setPriceAdjustmentForm({ + itemName: savedPriceAdjustmentForm.itemName || '', + adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '', + majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '', + adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '', + rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '', + referenceDate: savedPriceAdjustmentForm.referenceDate || '', + comparisonDate: savedPriceAdjustmentForm.comparisonDate || '', + adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio || '', + notes: savedPriceAdjustmentForm.notes || '', + adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '', + majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '', + adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '', + contractorWriter: savedPriceAdjustmentForm.contractorWriter || '', + adjustmentDate: savedPriceAdjustmentForm.adjustmentDate || '', + nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '', + }) + } + } } catch (error) { console.error('Failed to load pre-quote data:', error) } @@ -229,23 +409,38 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD if (participated && updatedDetail.biddingCompanyId) { try { const preQuoteData = await getSavedPrItemQuotations(updatedDetail.biddingCompanyId) - const convertedQuotations = preQuoteData.map(item => ({ - prItemId: item.prItemId, - bidUnitPrice: item.bidUnitPrice, - bidAmount: item.bidAmount, - proposedDeliveryDate: item.proposedDeliveryDate || undefined, - technicalSpecification: item.technicalSpecification || undefined - })) - - setPrItemQuotations(convertedQuotations) - const total = convertedQuotations.reduce((sum, q) => sum + q.bidAmount, 0) - setTotalQuotationAmount(total) - - if (total > 0) { - setResponseData(prev => ({ - ...prev, - finalQuoteAmount: total.toString() - })) + + if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) { + console.log('참여확정 후 사전견적 데이터:', preQuoteData) + + const convertedQuotations = preQuoteData + .filter(item => item && typeof item === 'object' && item.prItemId) + .map(item => ({ + prItemId: item.prItemId, + bidUnitPrice: item.bidUnitPrice, + bidAmount: item.bidAmount, + proposedDeliveryDate: item.proposedDeliveryDate || undefined, + technicalSpecification: item.technicalSpecification || undefined + })) + + console.log('참여확정 후 변환된 견적 데이터:', convertedQuotations) + + if (Array.isArray(convertedQuotations) && convertedQuotations.length > 0) { + setPrItemQuotations(convertedQuotations) + const total = convertedQuotations.reduce((sum, q) => { + const amount = Number(q.bidAmount) || 0 + return sum + amount + }, 0) + setTotalQuotationAmount(total) + console.log('참여확정 후 계산된 총 금액:', total) + + if (total > 0) { + setResponseData(prev => ({ + ...prev, + finalQuoteAmount: total.toString() + })) + } + } } } catch (error) { console.error('Failed to load pre-quote data after participation:', error) @@ -356,6 +551,59 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD } } + // 응찰 취소 핸들러 + const handleCancelResponse = async () => { + if (!biddingDetail || !userId) return + + // 최종제출한 경우 취소 불가 + if (biddingDetail.isFinalSubmission) { + toast({ + title: '취소 불가', + description: '최종 제출된 응찰은 취소할 수 없습니다.', + variant: 'destructive', + }) + return + } + + if (isBrowser && !window.confirm('응찰을 취소하시겠습니까? 작성한 견적 내용이 모두 삭제됩니다.')) { + return + } + + setIsCancelling(true) + try { + const result = await cancelBiddingResponse(biddingDetail.biddingCompanyId, userId) + + if (result.success) { + toast({ + title: '응찰 취소 완료', + description: '응찰이 취소되었습니다.', + }) + // 페이지 새로고침 + if (isBrowser) { + window.location.reload() + } else { + // 서버사이드에서는 라우터로 이동 + router.push(`/partners/bid/${biddingId}`) + } + } else { + toast({ + title: '응찰 취소 실패', + description: result.error, + variant: 'destructive', + }) + } + } catch (error) { + console.error('Failed to cancel bidding response:', error) + toast({ + title: '오류', + description: '응찰 취소에 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsCancelling(false) + } + } + const handleSubmitResponse = () => { if (!biddingDetail) return // 입찰 마감 상태 체크 @@ -412,6 +660,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD finalQuoteAmount: parseFloat(responseData.finalQuoteAmount), proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, additionalProposals: responseData.additionalProposals, + isFinalSubmission, // 최종제출 여부 추가 prItemQuotations: prItemQuotations.length > 0 ? prItemQuotations.map(q => ({ prItemId: q.prItemId, bidUnitPrice: q.bidUnitPrice, @@ -425,8 +674,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD if (result.success) { toast({ - title: '응찰 완료', - description: '견적이 성공적으로 제출되었습니다.', + title: isFinalSubmission ? '응찰 완료' : '임시 저장 완료', + description: isFinalSubmission ? '견적이 최종 제출되었습니다.' : '견적이 임시 저장되었습니다.', }) // 데이터 새로고침 @@ -488,9 +737,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <Badge variant="outline" className="font-mono text-xs"> {biddingDetail.biddingNumber} </Badge> - <Badge variant="outline" className="font-mono"> - Rev. {biddingDetail.revision ?? 0} - </Badge> <Badge variant={ biddingDetail.status === 'bidding_disposal' ? 'destructive' : biddingDetail.status === 'vendor_selected' ? 'default' : @@ -525,20 +771,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </CardHeader> <CardContent className="space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div> - <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label> - <div className="flex items-center gap-2 mt-1"> - <Building2 className="w-4 h-4" /> - <span>{biddingDetail.projectName || '미설정'}</span> - </div> - </div> - <div> - <Label className="text-sm font-medium text-muted-foreground">품목</Label> - <div className="flex items-center gap-2 mt-1"> - <Package className="w-4 h-4" /> - <span>{biddingDetail.itemName || '미설정'}</span> - </div> - </div> + <div> <Label className="text-sm font-medium text-muted-foreground">계약구분</Label> <div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div> @@ -552,22 +785,87 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : biddingDetail.awardCount === 'multiple' ? '복수' : '미설정'}</div> </div> <div> - <Label className="text-sm font-medium text-muted-foreground">입찰 담당자</Label> + <Label className="text-sm font-medium text-muted-foreground">입찰담당자</Label> <div className="flex items-center gap-2 mt-1"> <User className="w-4 h-4" /> - <span>{biddingDetail.managerName || '미설정'}</span> + <span>{biddingDetail.bidPicName || '미설정'}</span> </div> </div> - </div> - - {/* {biddingDetail.budget && ( <div> - <Label className="text-sm font-medium text-muted-foreground">예산</Label> + <Label className="text-sm font-medium text-muted-foreground">조달담당자</Label> <div className="flex items-center gap-2 mt-1"> - <DollarSign className="w-4 h-4" /> - <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span> + <User className="w-4 h-4" /> + <span>{biddingDetail.supplyPicName || '미설정'}</span> + </div> + </div> + </div> + + {/* 계약기간 */} + {biddingDetail.contractStartDate && biddingDetail.contractEndDate && ( + <div className="pt-4 border-t"> + <Label className="text-sm font-medium text-muted-foreground mb-2 block">계약기간</Label> + <div className="p-3 bg-muted/50 rounded-lg"> + <div className="flex items-center gap-2 text-sm"> + <span className="font-medium">{formatDate(biddingDetail.contractStartDate, 'KR')}</span> + <span className="text-muted-foreground">~</span> + <span className="font-medium">{formatDate(biddingDetail.contractEndDate, 'KR')}</span> + </div> </div> </div> + )} + + + {/* 제출 마감일 D-day */} + {/* {biddingDetail.submissionEndDate && ( + <div className="pt-4 border-t"> + <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label> + {(() => { + const now = new Date() + const deadline = new Date(biddingDetail.submissionEndDate) + const isExpired = deadline < now + const timeLeft = deadline.getTime() - now.getTime() + const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) + const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + + return ( + <div className={`p-3 rounded-lg border-2 ${ + isExpired + ? 'border-red-200 bg-red-50' + : daysLeft <= 1 + ? 'border-orange-200 bg-orange-50' + : 'border-green-200 bg-green-50' + }`}> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <Calendar className="w-5 h-5" /> + <span className="font-medium">제출 마감일:</span> + <span className="text-lg font-semibold"> + {formatDate(biddingDetail.submissionEndDate, 'KR')} + </span> + </div> + {isExpired ? ( + <Badge variant="destructive" className="ml-2"> + 마감됨 + </Badge> + ) : daysLeft <= 1 ? ( + <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800"> + {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`} + </Badge> + ) : ( + <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800"> + {daysLeft}일 남음 + </Badge> + )} + </div> + {isExpired && ( + <div className="mt-2 text-sm text-red-600"> + ⚠️ 제출 마감일이 지났습니다. 입찰 제출이 불가능합니다. + </div> + )} + </div> + ) + })()} + </div> )} */} {/* 일정 정보 */} @@ -576,7 +874,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm"> {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && ( <div> - <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')} + <span className="font-medium">응찰기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')} </div> )} {biddingDetail.evaluationDate && ( @@ -589,6 +887,130 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD </CardContent> </Card> + {/* 입찰공고 토글 섹션 */} + {biddingNotice && ( + <Card> + <Collapsible open={isNoticeOpen} onOpenChange={setIsNoticeOpen}> + <CollapsibleTrigger asChild> + <CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors"> + <CardTitle className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + 입찰공고 내용 + </div> + <ChevronDown className={`w-5 h-5 transition-transform ${isNoticeOpen ? 'rotate-180' : ''}`} /> + </CardTitle> + </CardHeader> + </CollapsibleTrigger> + <CollapsibleContent> + <CardContent className="pt-0"> + <div className="p-4 bg-muted/50 rounded-lg"> + {biddingNotice.title && ( + <h3 className="font-semibold text-lg mb-3">{biddingNotice.title}</h3> + )} + {biddingNotice.content ? ( + <div + className="prose prose-sm max-w-none" + dangerouslySetInnerHTML={{ + __html: biddingNotice.content + }} + /> + ) : ( + <p className="text-muted-foreground">입찰공고 내용이 없습니다.</p> + )} + </div> + </CardContent> + </CollapsibleContent> + </Collapsible> + </Card> + )} + + {/* 현재 설정된 조건 섹션 */} + {biddingConditions && ( + <Card> + <CardHeader> + <CardTitle>현재 설정된 입찰 조건</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm"> + <div> + <Label className="text-muted-foreground">지급조건</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.paymentTerms || "미설정"}</p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">부가세구분</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium"> + {biddingConditions.taxConditions + ? getTaxConditionName(biddingConditions.taxConditions) + : "미설정" + } + </p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">인도조건</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.incoterms || "미설정"}</p> + </div> + </div> + <div> + <Label className="text-muted-foreground">인도조건2</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.incotermsOption || "미설정"}</p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">계약 납기일</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium"> + {biddingConditions.contractDeliveryDate + ? formatDate(biddingConditions.contractDeliveryDate, 'KR') + : "미설정" + } + </p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">선적지</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.shippingPort || "미설정"}</p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">하역지</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.destinationPort || "미설정"}</p> + </div> + </div> + + <div> + <Label className="text-muted-foreground">연동제 적용</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p> + </div> + </div> + + + <div > + <Label className="text-muted-foreground">스페어파트 옵션</Label> + <div className="mt-1 p-3 bg-muted rounded-md"> + <p className="font-medium">{biddingConditions.sparePartOptions}</p> + </div> + </div> + </div> + </CardContent> + </Card> + )} + + {/* 참여 상태에 따른 섹션 표시 */} {biddingDetail.isBiddingParticipated === false ? ( @@ -687,25 +1109,315 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD readOnly={false} /> )} + + {/* 연동제 적용 여부 - SHI가 연동제를 요구하고, 사전견적에서 답변하지 않은 경우만 표시 */} + {biddingConditions?.isPriceAdjustmentApplicable && biddingDetail.priceAdjustmentResponse === null && ( + <> + <div className="space-y-3 p-4 border rounded-lg bg-muted/30"> + <Label className="font-semibold text-base">연동제 적용 여부 *</Label> + <RadioGroup + value={responseData.priceAdjustmentResponse === null ? 'none' : responseData.priceAdjustmentResponse ? 'apply' : 'not-apply'} + onValueChange={(value) => { + const newValue = value === 'apply' ? true : value === 'not-apply' ? false : null + setResponseData({...responseData, priceAdjustmentResponse: newValue}) + }} + > + <div className="flex items-center space-x-2"> + <RadioGroupItem value="apply" id="price-adjustment-apply" /> + <Label htmlFor="price-adjustment-apply" className="font-normal cursor-pointer"> + 연동제 적용 + </Label> + </div> + <div className="flex items-center space-x-2"> + <RadioGroupItem value="not-apply" id="price-adjustment-not-apply" /> + <Label htmlFor="price-adjustment-not-apply" className="font-normal cursor-pointer"> + 연동제 미적용 + </Label> + </div> + </RadioGroup> + </div> + + {/* 연동제 상세 정보 */} + {responseData.priceAdjustmentResponse !== null && ( + <Card className="mt-6"> + <CardHeader> + <CardTitle className="text-lg">하도급대금등 연동표</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 공통 필드 - 품목등의 명칭 */} + <div className="space-y-2"> + <Label htmlFor="itemName">품목등의 명칭 *</Label> + <Input + id="itemName" + value={priceAdjustmentForm.itemName} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, itemName: e.target.value})} + placeholder="품목명을 입력하세요" + required + /> + </div> + + {/* 연동제 적용 시 - 모든 필드 표시 */} + {responseData.priceAdjustmentResponse === true && ( + <> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="space-y-2"> + <Label htmlFor="adjustmentReflectionPoint">조정대금 반영시점 *</Label> + <Input + id="adjustmentReflectionPoint" + value={priceAdjustmentForm.adjustmentReflectionPoint} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentReflectionPoint: e.target.value})} + placeholder="반영시점을 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentRatio">연동 비율 (%) *</Label> + <Input + id="adjustmentRatio" + type="number" + step="0.01" + value={priceAdjustmentForm.adjustmentRatio} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentRatio: e.target.value})} + placeholder="비율을 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentPeriod">조정주기 *</Label> + <Input + id="adjustmentPeriod" + value={priceAdjustmentForm.adjustmentPeriod} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentPeriod: e.target.value})} + placeholder="조정주기를 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="referenceDate">기준시점 *</Label> + <Input + id="referenceDate" + type="date" + value={priceAdjustmentForm.referenceDate} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, referenceDate: e.target.value})} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="comparisonDate">비교시점 *</Label> + <Input + id="comparisonDate" + type="date" + value={priceAdjustmentForm.comparisonDate} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, comparisonDate: e.target.value})} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자 *</Label> + <Input + id="contractorWriter" + value={priceAdjustmentForm.contractorWriter} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})} + placeholder="작성자명을 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentDate">조정일 *</Label> + <Input + id="adjustmentDate" + type="date" + value={priceAdjustmentForm.adjustmentDate} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentDate: e.target.value})} + required + /> + </div> + </div> + + <div className="space-y-2"> + <Label htmlFor="majorApplicableRawMaterial">연동대상 주요 원재료 *</Label> + <Textarea + id="majorApplicableRawMaterial" + value={priceAdjustmentForm.majorApplicableRawMaterial} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorApplicableRawMaterial: e.target.value})} + placeholder="연동 대상 원재료를 입력하세요" + rows={3} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentFormula">하도급대금등 연동 산식 *</Label> + <Textarea + id="adjustmentFormula" + value={priceAdjustmentForm.adjustmentFormula} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentFormula: e.target.value})} + placeholder="연동 산식을 입력하세요" + rows={3} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="rawMaterialPriceIndex">원재료 가격 기준지표 *</Label> + <Textarea + id="rawMaterialPriceIndex" + value={priceAdjustmentForm.rawMaterialPriceIndex} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, rawMaterialPriceIndex: e.target.value})} + placeholder="가격 기준지표를 입력하세요" + rows={2} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="adjustmentConditions">조정요건 *</Label> + <Textarea + id="adjustmentConditions" + value={priceAdjustmentForm.adjustmentConditions} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentConditions: e.target.value})} + placeholder="조정요건을 입력하세요" + rows={2} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="priceAdjustmentNotes">기타 사항</Label> + <Textarea + id="priceAdjustmentNotes" + value={priceAdjustmentForm.notes} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, notes: e.target.value})} + placeholder="기타 사항을 입력하세요" + rows={2} + /> + </div> + </> + )} + + {/* 연동제 미적용 시 - 제한된 필드만 표시 */} + {responseData.priceAdjustmentResponse === false && ( + <> + <div className="space-y-2"> + <Label htmlFor="majorNonApplicableRawMaterial">연동제 미적용 주요 원재료 *</Label> + <Textarea + id="majorNonApplicableRawMaterial" + value={priceAdjustmentForm.majorNonApplicableRawMaterial} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorNonApplicableRawMaterial: e.target.value})} + placeholder="연동 미적용 원재료를 입력하세요" + rows={2} + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자 *</Label> + <Input + id="contractorWriter" + value={priceAdjustmentForm.contractorWriter} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})} + placeholder="작성자명을 입력하세요" + required + /> + </div> + + <div className="space-y-2"> + <Label htmlFor="nonApplicableReason">연동제 미희망 사유 *</Label> + <Textarea + id="nonApplicableReason" + value={priceAdjustmentForm.nonApplicableReason} + onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, nonApplicableReason: e.target.value})} + placeholder="미희망 사유를 입력하세요" + rows={3} + required + /> + </div> + </> + )} + </CardContent> + </Card> + )} + </> + )} + + {/* 사전견적에서 이미 답변한 경우 - 읽기 전용으로 표시 */} + {biddingDetail.priceAdjustmentResponse !== null && ( + <Card> + <CardHeader> + <CardTitle className="text-lg">연동제 적용 정보 (사전견적 제출 완료)</CardTitle> + </CardHeader> + <CardContent> + <div className="p-4 bg-muted/30 rounded-lg"> + <div className="flex items-center gap-2 mb-2"> + <CheckCircle className="w-5 h-5 text-green-600" /> + <span className="font-semibold"> + {biddingDetail.priceAdjustmentResponse ? '연동제 적용' : '연동제 미적용'} + </span> + </div> + <p className="text-sm text-muted-foreground"> + 사전견적에서 이미 연동제 관련 정보를 제출하였습니다. 본입찰에서는 별도의 연동제 정보 입력이 필요하지 않습니다. + </p> + </div> + </CardContent> + </Card> + )} + + {/* 최종제출 체크박스 */} + {!biddingDetail.isFinalSubmission && ( + <div className="flex items-center space-x-2 p-4 border rounded-lg bg-muted/30"> + <input + type="checkbox" + id="finalSubmission" + checked={isFinalSubmission} + onChange={(e) => setIsFinalSubmission(e.target.checked)} + disabled={isSubmitting || isSavingDraft} + className="h-4 w-4 rounded border-gray-300" + /> + <label htmlFor="finalSubmission" className="text-sm font-medium cursor-pointer"> + 최종 제출 (체크 시 제출 후 수정 및 취소 불가) + </label> + </div> + )} + {/* 응찰 제출 버튼 - 참여 확정 상태일 때만 표시 */} - <div className="flex justify-end pt-4 gap-2"> - <Button - variant="outline" - onClick={handleSaveDraft} - disabled={isSavingDraft || isSubmitting} - className="min-w-[100px]" - > - <Save className="w-4 h-4 mr-2" /> - {isSavingDraft ? '저장 중...' : '임시 저장'} - </Button> - <Button - onClick={handleSubmitResponse} - disabled={isSubmitting || isSavingDraft || !!biddingDetail.responseSubmittedAt} - className="min-w-[100px]" - > - <Send className="w-4 h-4 mr-2" /> - {isSubmitting ? '제출 중...' : biddingDetail.responseSubmittedAt ? '응찰 완료' : '응찰 제출'} - </Button> + <div className="flex justify-between pt-4 gap-2"> + {/* 응찰 취소 버튼 (최종제출 아닌 경우만) */} + {biddingDetail.finalQuoteSubmittedAt && !biddingDetail.isFinalSubmission && ( + <Button + variant="destructive" + onClick={handleCancelResponse} + disabled={isCancelling || isSubmitting} + className="min-w-[100px]" + > + <Trash2 className="w-4 h-4 mr-2" /> + {isCancelling ? '취소 중...' : '응찰 취소'} + </Button> + )} + <div className="flex gap-2 ml-auto"> + <Button + variant="outline" + onClick={handleSaveDraft} + disabled={isSavingDraft || isSubmitting || biddingDetail.isFinalSubmission} + className="min-w-[100px]" + > + <Save className="w-4 h-4 mr-2" /> + {isSavingDraft ? '저장 중...' : '임시 저장'} + </Button> + <Button + onClick={handleSubmitResponse} + disabled={isSubmitting || isSavingDraft || biddingDetail.isFinalSubmission} + className="min-w-[100px]" + > + <Send className="w-4 h-4 mr-2" /> + {isSubmitting ? '제출 중...' : biddingDetail.isFinalSubmission ? '최종 제출 완료' : '응찰 제출'} + </Button> + </div> </div> </CardContent> </Card> diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index 7fb62122..5870067a 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -17,7 +17,6 @@ import { MoreHorizontal, Calendar, User, - Calculator, Paperclip, AlertTriangle } from 'lucide-react' @@ -25,6 +24,7 @@ import { formatDate } from '@/lib/utils' import { biddingStatusLabels, contractTypeLabels } from '@/db/schema' import { PartnersBiddingListItem } from '../detail/service' import { Checkbox } from '@/components/ui/checkbox' +import { toast } from 'sonner' const columnHelper = createColumnHelper<PartnersBiddingListItem>() @@ -62,11 +62,15 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL header: '입찰 No.', cell: ({ row }) => { const biddingNumber = row.original.biddingNumber + const originalBiddingNumber = row.original.originalBiddingNumber const revision = row.original.revision return ( <div className="font-mono text-sm"> <div>{biddingNumber}</div> - <div className="text-muted-foreground">Rev. {revision ?? 0}</div> + <div className="text-muted-foreground text-xs">Rev. {revision ?? 0}</div> + {originalBiddingNumber && ( + <div className="text-xs text-muted-foreground">원: {originalBiddingNumber}</div> + )} </div> ) }, @@ -148,27 +152,36 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL id: 'actions', header: '액션', cell: ({ row }) => { - const handleView = () => { - if (setRowAction) { - setRowAction({ - type: 'view', - row: { original: row.original } + // 사양설명회 참석여부 체크 함수 + const checkSpecificationMeeting = () => { + const hasSpecMeeting = row.original.hasSpecificationMeeting + const isAttending = row.original.isAttendingMeeting + + // 사양설명회가 있고, 참석여부가 아직 설정되지 않은 경우 + if (hasSpecMeeting && isAttending === null) { + toast.warning('사양설명회 참석여부 필요', { + description: '사전견적 또는 입찰을 진행하기 전에 사양설명회 참석여부를 먼저 설정해주세요.', + duration: 5000, }) + return false } + return true } - const handlePreQuote = () => { + const handleView = () => { + // 사양설명회 체크 + if (!checkSpecificationMeeting()) { + return + } + if (setRowAction) { setRowAction({ - type: 'pre-quote', + type: 'view', row: { original: row.original } }) } } - const biddingStatus = row.original.status - const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' - return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -185,12 +198,6 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL <FileText className="mr-2 h-4 w-4" /> 입찰 상세보기 </DropdownMenuItem> - {!isClosed && ( - <DropdownMenuItem onClick={handlePreQuote}> - <Calculator className="mr-2 h-4 w-4" /> - 사전견적하기 - </DropdownMenuItem> - )} </DropdownMenuContent> </DropdownMenu> ) @@ -327,61 +334,50 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL const endDate = row.original.contractEndDate if (!startDate || !endDate) { - return <div className="max-w-24 truncate">-</div> + return <div className="text-muted-foreground text-center">-</div> } return ( - <div className="max-w-24 truncate" title={`${formatDate(startDate, 'KR')} ~ ${formatDate(endDate, 'KR')}`}> - {formatDate(startDate, 'KR')} ~ {formatDate(endDate, 'KR')} + <div className="text-sm"> + <div>{formatDate(startDate, 'KR')}</div> + <div className="text-muted-foreground">~</div> + <div>{formatDate(endDate, 'KR')}</div> </div> ) }, }), - // 참여회신 마감일 - columnHelper.accessor('responseDeadline', { - header: '참여회신 마감일', - cell: ({ row }) => { - const deadline = row.original.responseDeadline - if (!deadline) { - return <div className="text-muted-foreground">-</div> - } - return <div className="text-sm">{formatDate(deadline, 'KR')}</div> - }, - }), - - // 입찰제출일 - columnHelper.accessor('submissionDate', { - header: '입찰제출일', + // 입찰담당자 + columnHelper.display({ + id: 'bidPicName', + header: '입찰담당자', cell: ({ row }) => { - const date = row.original.submissionDate - if (!date) { - return <div className="text-muted-foreground">-</div> + const name = row.original.bidPicName + if (!name) { + return <div className="text-muted-foreground text-center">-</div> } - return <div className="text-sm">{formatDate(date, 'KR')}</div> + return ( + <div className="flex items-center gap-1"> + <User className="h-4 w-4" /> + <div className="text-sm">{name}</div> + </div> + ) }, }), - // 입찰담당자 - columnHelper.accessor('managerName', { - header: '입찰담당자', + // 조달담당자 + columnHelper.display({ + id: 'supplyPicName', + header: '조달담당자', cell: ({ row }) => { - const name = row.original.managerName - const email = row.original.managerEmail + const name = row.original.supplyPicName if (!name) { - return <div className="text-muted-foreground">-</div> + return <div className="text-muted-foreground text-center">-</div> } return ( <div className="flex items-center gap-1"> <User className="h-4 w-4" /> - <div> - <div className="text-sm">{name}</div> - {email && ( - <div className="text-xs text-muted-foreground truncate max-w-32" title={email}> - {email} - </div> - )} - </div> + <div className="text-sm">{name}</div> </div> ) }, diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx deleted file mode 100644 index 8a157c5f..00000000 --- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx +++ /dev/null @@ -1,1413 +0,0 @@ -'use client' - -import * as React from 'react' -import { useRouter } from 'next/navigation' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' -import { Checkbox } from '@/components/ui/checkbox' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import { - ArrowLeft, - Calendar, - Building2, - Package, - User, - FileText, - Users, - Send, - CheckCircle, - XCircle, - Save -} from 'lucide-react' - -import { formatDate } from '@/lib/utils' -import { - getBiddingCompaniesForPartners, - submitPreQuoteResponse, - getPrItemsForBidding, - getSavedPrItemQuotations, - savePreQuoteDraft, - setPreQuoteParticipation -} from '../pre-quote/service' -import { getBiddingConditions } from '../service' -import { getPriceAdjustmentFormByBiddingCompanyId } from '../detail/service' -import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from '@/lib/procurement-select/service' -import { TAX_CONDITIONS, getTaxConditionName } from '@/lib/tax-conditions/types' -import { PrItemsPricingTable } from './components/pr-items-pricing-table' -import { SimpleFileUpload } from './components/simple-file-upload' -import { - biddingStatusLabels, -} from '@/db/schema' -import { useToast } from '@/hooks/use-toast' -import { useTransition } from 'react' -import { useSession } from 'next-auth/react' - -interface PartnersBiddingPreQuoteProps { - biddingId: number - companyId: number -} - -interface BiddingDetail { - id: number - biddingNumber: string - revision: number | null - projectName: string | null - itemName: string | null - title: string - description: string | null - content: string | null - contractType: string - biddingType: string - awardCount: string - contractStartDate: Date | null - contractEndDate: Date | null - preQuoteDate: string | null - biddingRegistrationDate: string | null - submissionStartDate: string | null - submissionEndDate: string | null - evaluationDate: string | null - currency: string - budget: number | null - targetPrice: number | null - status: string - managerName: string | null - managerEmail: string | null - managerPhone: string | null - biddingCompanyId: number | null - biddingId: number // bidding의 ID 추가 - invitationStatus: string | null - preQuoteAmount: string | null - preQuoteSubmittedAt: string | null - preQuoteDeadline: string | null - isPreQuoteSelected: boolean | null - isAttendingMeeting: boolean | null - // companyConditionResponses에서 가져온 조건들 (제시된 조건과 응답 모두) - paymentTermsResponse: string | null - taxConditionsResponse: string | null - incotermsResponse: string | null - proposedContractDeliveryDate: string | null - proposedShippingPort: string | null - proposedDestinationPort: string | null - priceAdjustmentResponse: boolean | null - sparePartResponse: string | null - isInitialResponse: boolean | null - additionalProposals: string | null -} - -export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddingPreQuoteProps) { - const router = useRouter() - const { toast } = useToast() - const [isPending, startTransition] = useTransition() - const session = useSession() - const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null) - const [isLoading, setIsLoading] = React.useState(true) - const [biddingConditions, setBiddingConditions] = React.useState<any | null>(null) - - // 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 [prItems, setPrItems] = React.useState<any[]>([]) - const [prItemQuotations, setPrItemQuotations] = React.useState<any[]>([]) - const [totalAmount, setTotalAmount] = React.useState(0) - const [isSaving, setIsSaving] = React.useState(false) - - // 사전견적 폼 상태 - const [responseData, setResponseData] = React.useState({ - preQuoteAmount: '', - paymentTermsResponse: '', - taxConditionsResponse: '', - incotermsResponse: '', - proposedContractDeliveryDate: '', - proposedShippingPort: '', - proposedDestinationPort: '', - priceAdjustmentResponse: false, - isInitialResponse: false, - sparePartResponse: '', - additionalProposals: '', - isAttendingMeeting: false, - }) - - // 사전견적 참여의사 상태 - const [participationDecision, setParticipationDecision] = React.useState<boolean | null>(null) - - // 연동제 폼 상태 - const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({ - itemName: '', - adjustmentReflectionPoint: '', - majorApplicableRawMaterial: '', - adjustmentFormula: '', - rawMaterialPriceIndex: '', - referenceDate: '', - comparisonDate: '', - adjustmentRatio: '', - notes: '', - adjustmentConditions: '', - majorNonApplicableRawMaterial: '', - adjustmentPeriod: '', - contractorWriter: '', - adjustmentDate: '', - nonApplicableReason: '', - }) - const userId = session.data?.user?.id || '' - - // Procurement 데이터 로드 함수들 - const loadPaymentTerms = React.useCallback(async () => { - try { - const data = await getPaymentTermsForSelection(); - setPaymentTermsOptions(data); - } catch (error) { - console.error("Failed to load payment terms:", error); - } - }, []); - - const loadIncoterms = React.useCallback(async () => { - try { - const data = await getIncotermsForSelection(); - setIncotermsOptions(data); - } catch (error) { - console.error("Failed to load incoterms:", error); - } - }, []); - - const loadShippingPlaces = React.useCallback(async () => { - try { - const data = await getPlaceOfShippingForSelection(); - setShippingPlaces(data); - } catch (error) { - console.error("Failed to load shipping places:", error); - } - }, []); - - const loadDestinationPlaces = React.useCallback(async () => { - try { - const data = await getPlaceOfDestinationForSelection(); - setDestinationPlaces(data); - } catch (error) { - console.error("Failed to load destination places:", error); - } - }, []); - - // 데이터 로드 - React.useEffect(() => { - const loadData = async () => { - try { - setIsLoading(true) - - // 모든 필요한 데이터를 병렬로 로드 - const [result, conditions, prItemsData] = await Promise.all([ - getBiddingCompaniesForPartners(biddingId, companyId), - getBiddingConditions(biddingId), - getPrItemsForBidding(biddingId) - ]) - - if (result) { - setBiddingDetail(result as BiddingDetail) - - // 저장된 품목별 견적 정보가 있으면 로드 - if (result.biddingCompanyId) { - const savedQuotations = await getSavedPrItemQuotations(result.biddingCompanyId) - setPrItemQuotations(savedQuotations) - - // 총 금액 계산 - const calculatedTotal = savedQuotations.reduce((sum: number, item: any) => sum + item.bidAmount, 0) - setTotalAmount(calculatedTotal) - - // 저장된 연동제 정보가 있으면 로드 - if (result.priceAdjustmentResponse) { - const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(result.biddingCompanyId) - if (savedPriceAdjustmentForm) { - setPriceAdjustmentForm({ - itemName: savedPriceAdjustmentForm.itemName || '', - adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '', - majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '', - adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '', - rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '', - referenceDate: savedPriceAdjustmentForm.referenceDate ? new Date(savedPriceAdjustmentForm.referenceDate).toISOString().split('T')[0] : '', - comparisonDate: savedPriceAdjustmentForm.comparisonDate ? new Date(savedPriceAdjustmentForm.comparisonDate).toISOString().split('T')[0] : '', - adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio?.toString() || '', - notes: savedPriceAdjustmentForm.notes || '', - adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '', - majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '', - adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '', - contractorWriter: savedPriceAdjustmentForm.contractorWriter || '', - adjustmentDate: savedPriceAdjustmentForm.adjustmentDate ? new Date(savedPriceAdjustmentForm.adjustmentDate).toISOString().split('T')[0] : '', - nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '', - }) - } - } - } - - // 기존 응답 데이터로 폼 초기화 - setResponseData({ - preQuoteAmount: result.preQuoteAmount?.toString() || '', - paymentTermsResponse: result.paymentTermsResponse || '', - taxConditionsResponse: result.taxConditionsResponse || '', - incotermsResponse: result.incotermsResponse || '', - proposedContractDeliveryDate: result.proposedContractDeliveryDate || '', - proposedShippingPort: result.proposedShippingPort || '', - proposedDestinationPort: result.proposedDestinationPort || '', - priceAdjustmentResponse: result.priceAdjustmentResponse || false, - isInitialResponse: result.isInitialResponse || false, - sparePartResponse: result.sparePartResponse || '', - additionalProposals: result.additionalProposals || '', - isAttendingMeeting: result.isAttendingMeeting || false, - }) - - // 사전견적 참여의사 초기화 - setParticipationDecision(result.isPreQuoteParticipated) - } - - if (conditions) { - // BiddingConditionsEdit와 같은 방식으로 raw 데이터 사용 - setBiddingConditions(conditions) - } - - if (prItemsData) { - setPrItems(prItemsData) - } - - // Procurement 데이터 로드 - await Promise.all([ - loadPaymentTerms(), - loadIncoterms(), - loadShippingPlaces(), - loadDestinationPlaces() - ]) - } catch (error) { - console.error('Failed to load bidding company:', error) - toast({ - title: '오류', - description: '입찰 정보를 불러오는데 실패했습니다.', - variant: 'destructive', - }) - } finally { - setIsLoading(false) - } - } - - loadData() - }, [biddingId, companyId, toast, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]) - - // 임시저장 기능 - const handleTempSave = () => { - if (!biddingDetail || !biddingDetail.biddingCompanyId) { - toast({ - title: '임시저장 실패', - description: '입찰 정보가 올바르지 않습니다.', - variant: 'destructive', - }) - return - } - // 입찰 마감 상태 체크 - const biddingStatus = biddingDetail.status - const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' - - if (isClosed) { - toast({ - title: "접근 제한", - description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.", - variant: "destructive", - }) - router.back() - return - } - - // 사전견적 상태 체크 - const isPreQuoteStatus = biddingStatus === 'request_for_quotation' || biddingStatus === 'received_quotation' - if (!isPreQuoteStatus) { - toast({ - title: "접근 제한", - description: "사전견적 단계가 아니므로 임시저장이 불가능합니다.", - variant: "destructive", - }) - return - } - - if (!userId) { - toast({ - title: '임시저장 실패', - description: '사용자 정보를 확인할 수 없습니다. 다시 로그인해주세요.', - variant: 'destructive', - }) - return - } - - setIsSaving(true) - startTransition(async () => { - try { - const result = await savePreQuoteDraft( - biddingDetail.biddingCompanyId!, - { - prItemQuotations, - paymentTermsResponse: responseData.paymentTermsResponse, - taxConditionsResponse: responseData.taxConditionsResponse, - incotermsResponse: responseData.incotermsResponse, - proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, - proposedShippingPort: responseData.proposedShippingPort, - proposedDestinationPort: responseData.proposedDestinationPort, - priceAdjustmentResponse: responseData.priceAdjustmentResponse || false, // 체크 안하면 false로 설정 - isInitialResponse: responseData.isInitialResponse || false, // 체크 안하면 false로 설정 - sparePartResponse: responseData.sparePartResponse, - additionalProposals: responseData.additionalProposals, - priceAdjustmentForm: (responseData.priceAdjustmentResponse || false) ? { - itemName: priceAdjustmentForm.itemName, - adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint, - majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial, - adjustmentFormula: priceAdjustmentForm.adjustmentFormula, - rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex, - referenceDate: priceAdjustmentForm.referenceDate, - comparisonDate: priceAdjustmentForm.comparisonDate, - adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined, - notes: priceAdjustmentForm.notes, - adjustmentConditions: priceAdjustmentForm.adjustmentConditions, - majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial, - adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod, - contractorWriter: priceAdjustmentForm.contractorWriter, - adjustmentDate: priceAdjustmentForm.adjustmentDate, - nonApplicableReason: priceAdjustmentForm.nonApplicableReason, - } : undefined - }, - userId - ) - - if (result.success) { - toast({ - title: '임시저장 완료', - description: result.message, - }) - } else { - toast({ - title: '임시저장 실패', - description: result.error, - variant: 'destructive', - }) - } - } catch (error) { - console.error('Temp save error:', error) - toast({ - title: '임시저장 실패', - description: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', - variant: 'destructive', - }) - } finally { - setIsSaving(false) - } - }) - } - - // 사전견적 참여의사 설정 함수 - const handleParticipationDecision = async (participate: boolean) => { - if (!biddingDetail?.biddingCompanyId) return - - startTransition(async () => { - const result = await setPreQuoteParticipation( - biddingDetail.biddingCompanyId!, - participate - ) - - if (result.success) { - setParticipationDecision(participate) - toast({ - title: '설정 완료', - description: `사전견적 ${participate ? '참여' : '미참여'}로 설정되었습니다.`, - }) - } else { - toast({ - title: '설정 실패', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - const handleSubmitResponse = () => { - if (!biddingDetail) return - - // 입찰 마감 상태 체크 - const biddingStatus = biddingDetail.status - const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal' - - if (isClosed) { - toast({ - title: "접근 제한", - description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.", - variant: "destructive", - }) - router.back() - return - } - - // 사전견적 상태 체크 - const isPreQuoteStatus = biddingStatus === 'request_for_quotation' || biddingStatus === 'received_quotation' - if (!isPreQuoteStatus) { - toast({ - title: "접근 제한", - description: "사전견적 단계가 아니므로 견적 제출이 불가능합니다.", - variant: "destructive", - }) - return - } - - // 견적마감일 체크 - if (biddingDetail.preQuoteDeadline) { - const now = new Date() - const deadline = new Date(biddingDetail.preQuoteDeadline) - if (deadline < now) { - toast({ - title: '견적 마감', - description: '견적 마감일이 지나 제출할 수 없습니다.', - variant: 'destructive', - }) - return - } - } - - // 필수값 검증 - if (prItemQuotations.length === 0 || totalAmount === 0) { - toast({ - title: '유효성 오류', - description: '품목별 견적을 입력해주세요.', - variant: 'destructive', - }) - return - } - - // 품목별 납품일 검증 - if (prItemQuotations.length > 0) { - for (const quotation of prItemQuotations) { - if (!quotation.proposedDeliveryDate?.trim()) { - const prItem = prItems.find(item => item.id === quotation.prItemId) - toast({ - title: '유효성 오류', - description: `품목 ${prItem?.itemNumber || quotation.prItemId}의 납품예정일을 입력해주세요.`, - variant: 'destructive', - }) - return - } - } - } - - const requiredFields = [ - { value: responseData.proposedContractDeliveryDate, name: '제안 납품일' }, - { value: responseData.paymentTermsResponse, name: '응답 지급조건' }, - { value: responseData.taxConditionsResponse, name: '응답 세금조건' }, - { value: responseData.incotermsResponse, name: '응답 운송조건' }, - { value: responseData.proposedShippingPort, name: '제안 선적지' }, - { value: responseData.proposedDestinationPort, name: '제안 하역지' }, - { value: responseData.sparePartResponse, name: '스페어파트 응답' }, - ] - - const missingField = requiredFields.find(field => !field.value?.trim()) - if (missingField) { - toast({ - title: '유효성 오류', - description: `${missingField.name}을(를) 입력해주세요.`, - variant: 'destructive', - }) - return - } - - startTransition(async () => { - const submissionData = { - preQuoteAmount: totalAmount, // 품목별 계산된 총 금액 사용 - prItemQuotations, // 품목별 견적 데이터 추가 - paymentTermsResponse: responseData.paymentTermsResponse, - taxConditionsResponse: responseData.taxConditionsResponse, - incotermsResponse: responseData.incotermsResponse, - proposedContractDeliveryDate: responseData.proposedContractDeliveryDate, - proposedShippingPort: responseData.proposedShippingPort, - proposedDestinationPort: responseData.proposedDestinationPort, - priceAdjustmentResponse: responseData.priceAdjustmentResponse || false, // 체크 안하면 false로 설정 - isInitialResponse: responseData.isInitialResponse || false, // 체크 안하면 false로 설정 - sparePartResponse: responseData.sparePartResponse, - additionalProposals: responseData.additionalProposals, - priceAdjustmentForm: (responseData.priceAdjustmentResponse || false) ? { - itemName: priceAdjustmentForm.itemName, - adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint, - majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial, - adjustmentFormula: priceAdjustmentForm.adjustmentFormula, - rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex, - referenceDate: priceAdjustmentForm.referenceDate, - comparisonDate: priceAdjustmentForm.comparisonDate, - adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined, - notes: priceAdjustmentForm.notes, - adjustmentConditions: priceAdjustmentForm.adjustmentConditions, - majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial, - adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod, - contractorWriter: priceAdjustmentForm.contractorWriter, - adjustmentDate: priceAdjustmentForm.adjustmentDate, - nonApplicableReason: priceAdjustmentForm.nonApplicableReason, - } : undefined - } - - const result = await submitPreQuoteResponse( - biddingDetail.biddingCompanyId!, - submissionData, - userId - ) - - console.log('제출 결과:', result) - - if (result.success) { - toast({ - title: '성공', - description: result.message, - }) - - // 데이터 새로고침 및 폼 상태 업데이트 - const updatedDetail = await getBiddingCompaniesForPartners(biddingId, companyId) - console.log('업데이트된 데이터:', updatedDetail) - - if (updatedDetail) { - setBiddingDetail(updatedDetail as BiddingDetail) - - // 폼 상태도 업데이트된 데이터로 다시 설정 - setResponseData({ - preQuoteAmount: updatedDetail.preQuoteAmount?.toString() || '', - paymentTermsResponse: updatedDetail.paymentTermsResponse || '', - taxConditionsResponse: updatedDetail.taxConditionsResponse || '', - incotermsResponse: updatedDetail.incotermsResponse || '', - proposedContractDeliveryDate: updatedDetail.proposedContractDeliveryDate || '', - proposedShippingPort: updatedDetail.proposedShippingPort || '', - proposedDestinationPort: updatedDetail.proposedDestinationPort || '', - priceAdjustmentResponse: updatedDetail.priceAdjustmentResponse || false, - isInitialResponse: updatedDetail.isInitialResponse || false, - sparePartResponse: updatedDetail.sparePartResponse || '', - additionalProposals: updatedDetail.additionalProposals || '', - isAttendingMeeting: updatedDetail.isAttendingMeeting || false, - }) - - // 연동제 데이터도 다시 로드 - if (updatedDetail.biddingCompanyId && updatedDetail.priceAdjustmentResponse) { - const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(updatedDetail.biddingCompanyId) - if (savedPriceAdjustmentForm) { - setPriceAdjustmentForm({ - itemName: savedPriceAdjustmentForm.itemName || '', - adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '', - majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '', - adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '', - rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '', - referenceDate: savedPriceAdjustmentForm.referenceDate ? new Date(savedPriceAdjustmentForm.referenceDate).toISOString().split('T')[0] : '', - comparisonDate: savedPriceAdjustmentForm.comparisonDate ? new Date(savedPriceAdjustmentForm.comparisonDate).toISOString().split('T')[0] : '', - adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio?.toString() || '', - notes: savedPriceAdjustmentForm.notes || '', - adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '', - majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '', - adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '', - contractorWriter: savedPriceAdjustmentForm.contractorWriter || '', - adjustmentDate: savedPriceAdjustmentForm.adjustmentDate ? new Date(savedPriceAdjustmentForm.adjustmentDate).toISOString().split('T')[0] : '', - nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '', - }) - } - } - } - } else { - toast({ - title: '오류', - description: result.error, - variant: 'destructive', - }) - } - }) - } - - - if (isLoading) { - return ( - <div className="flex items-center justify-center py-12"> - <div className="text-center"> - <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> - <p className="text-muted-foreground">입찰 정보를 불러오는 중...</p> - </div> - </div> - ) - } - - if (!biddingDetail) { - return ( - <div className="text-center py-12"> - <p className="text-muted-foreground">입찰 정보를 찾을 수 없습니다.</p> - <Button onClick={() => router.back()} className="mt-4"> - <ArrowLeft className="w-4 h-4 mr-2" /> - 돌아가기 - </Button> - </div> - ) - } - - return ( - <div className="space-y-6"> - {/* 헤더 */} - <div className="flex items-center justify-between"> - <div className="flex items-center gap-4"> - <Button variant="outline" onClick={() => router.back()}> - <ArrowLeft className="w-4 h-4 mr-2" /> - 목록으로 - </Button> - <div> - <h1 className="text-2xl font-semibold">{biddingDetail.title}</h1> - <div className="flex items-center gap-2 mt-1"> - <Badge variant="outline" className="font-mono"> - {biddingDetail.biddingNumber} - {biddingDetail.revision && biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`} - </Badge> - <Badge variant={ - biddingDetail.status === 'bidding_disposal' ? 'destructive' : - biddingDetail.status === 'vendor_selected' ? 'default' : - 'secondary' - }> - {biddingStatusLabels[biddingDetail.status]} - </Badge> - </div> - </div> - </div> - - </div> - - {/* 입찰 공고 섹션 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <FileText className="w-5 h-5" /> - 입찰 공고 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div> - <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label> - <div className="flex items-center gap-2 mt-1"> - <Building2 className="w-4 h-4" /> - <span>{biddingDetail.projectName}</span> - </div> - </div> - <div> - <Label className="text-sm font-medium text-muted-foreground">품목</Label> - <div className="flex items-center gap-2 mt-1"> - <Package className="w-4 h-4" /> - <span>{biddingDetail.itemName}</span> - </div> - </div> - {/* <div> - <Label className="text-sm font-medium text-muted-foreground">계약구분</Label> - <div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div> - </div> - <div> - <Label className="text-sm font-medium text-muted-foreground">입찰유형</Label> - <div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div> - </div> - <div> - <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label> - <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div> - </div> */} - <div> - <Label className="text-sm font-medium text-muted-foreground">담당자</Label> - <div className="flex items-center gap-2 mt-1"> - <User className="w-4 h-4" /> - <span>{biddingDetail.managerName}</span> - </div> - </div> - </div> - - {/* {biddingDetail.budget && ( - <div> - <Label className="text-sm font-medium text-muted-foreground">예산</Label> - <div className="flex items-center gap-2 mt-1"> - <DollarSign className="w-4 h-4" /> - <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span> - </div> - </div> - )} */} - - {/* 일정 정보 */} - {/* <div className="pt-4 border-t"> - <Label className="text-sm font-medium text-muted-foreground mb-2 block">일정 정보</Label> - <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm"> - {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && ( - <div> - <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')} - </div> - )} - {biddingDetail.evaluationDate && ( - <div> - <span className="font-medium">평가일:</span> {formatDate(biddingDetail.evaluationDate, 'KR')} - </div> - )} - </div> - </div> */} - - {/* 견적마감일 정보 */} - {biddingDetail.preQuoteDeadline && ( - <div className="pt-4 border-t"> - <Label className="text-sm font-medium text-muted-foreground mb-2 block">견적 마감 정보</Label> - {(() => { - const now = new Date() - const deadline = new Date(biddingDetail.preQuoteDeadline) - const isExpired = deadline < now - const timeLeft = deadline.getTime() - now.getTime() - const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24)) - const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) - - return ( - <div className={`p-3 rounded-lg border-2 ${ - isExpired - ? 'border-red-200 bg-red-50' - : daysLeft <= 1 - ? 'border-orange-200 bg-orange-50' - : 'border-green-200 bg-green-50' - }`}> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-2"> - <Calendar className="w-5 h-5" /> - <span className="font-medium">견적 마감일:</span> - <span className="text-lg font-semibold"> - {formatDate(biddingDetail.preQuoteDeadline, 'KR')} - </span> - </div> - {isExpired ? ( - <Badge variant="destructive" className="ml-2"> - 마감됨 - </Badge> - ) : daysLeft <= 1 ? ( - <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800"> - {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`} - </Badge> - ) : ( - <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800"> - {daysLeft}일 남음 - </Badge> - )} - </div> - {isExpired && ( - <div className="mt-2 text-sm text-red-600"> - ⚠️ 견적 마감일이 지났습니다. 견적 제출이 불가능합니다. - </div> - )} - </div> - ) - })()} - </div> - )} - </CardContent> - </Card> - - {/* 현재 설정된 조건 섹션 */} - {biddingConditions && ( - <Card> - <CardHeader> - <CardTitle>현재 설정된 입찰 조건</CardTitle> - </CardHeader> - <CardContent> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm"> - <div> - <Label className="text-muted-foreground">지급조건</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - <p className="font-medium">{biddingConditions.paymentTerms || "미설정"}</p> - </div> - </div> - - <div> - <Label className="text-muted-foreground">세금조건</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - <p className="font-medium"> - {biddingConditions.taxConditions - ? getTaxConditionName(biddingConditions.taxConditions) - : "미설정" - } - </p> - </div> - </div> - - <div> - <Label className="text-muted-foreground">운송조건</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - <p className="font-medium">{biddingConditions.incoterms || "미설정"}</p> - </div> - </div> - - <div> - <Label className="text-muted-foreground">계약 납기일</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - <p className="font-medium"> - {biddingConditions.contractDeliveryDate - ? formatDate(biddingConditions.contractDeliveryDate, 'KR') - : "미설정" - } - </p> - </div> - </div> - - <div> - <Label className="text-muted-foreground">선적지</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - <p className="font-medium">{biddingConditions.shippingPort || "미설정"}</p> - </div> - </div> - - <div> - <Label className="text-muted-foreground">하역지</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - <p className="font-medium">{biddingConditions.destinationPort || "미설정"}</p> - </div> - </div> - - <div> - <Label className="text-muted-foreground">연동제 적용</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - <p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p> - </div> - </div> - - - <div > - <Label className="text-muted-foreground">스페어파트 옵션</Label> - <div className="mt-1 p-3 bg-muted rounded-md"> - <p className="font-medium">{biddingConditions.sparePartOptions}</p> - </div> - </div> - </div> - </CardContent> - </Card> - )} - - {/* 사전견적 참여의사 결정 섹션 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Users className="w-5 h-5" /> - 사전견적 참여의사 결정 - </CardTitle> - </CardHeader> - <CardContent> - {participationDecision === null ? ( - <div className="space-y-4"> - <p className="text-muted-foreground"> - 해당 입찰의 사전견적에 참여하시겠습니까? - </p> - <div className="flex gap-3"> - <Button - onClick={() => handleParticipationDecision(true)} - disabled={isPending} - className="flex items-center gap-2" - > - <CheckCircle className="w-4 h-4" /> - 참여 - </Button> - <Button - variant="outline" - onClick={() => handleParticipationDecision(false)} - disabled={isPending} - className="flex items-center gap-2" - > - <XCircle className="w-4 h-4" /> - 미참여 - </Button> - </div> - </div> - ) : ( - <div className="space-y-4"> - <div className={`flex items-center gap-2 p-3 rounded-lg ${ - participationDecision ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800' - }`}> - {participationDecision ? ( - <CheckCircle className="w-5 h-5" /> - ) : ( - <XCircle className="w-5 h-5" /> - )} - <span className="font-medium"> - 사전견적 {participationDecision ? '참여' : '미참여'}로 설정되었습니다. - </span> - </div> - {participationDecision === false && ( - <> - <div className="p-4 bg-muted rounded-lg"> - <p className="text-muted-foreground"> - 미참여로 설정되어 견적 작성 섹션이 숨겨집니다. 참여하시려면 아래 버튼을 클릭해주세요. - </p> - </div> - - <Button - variant="outline" - size="sm" - onClick={() => setParticipationDecision(null)} - disabled={isPending} - > - 결정 변경하기 - </Button> - </> - )} - </div> - )} - </CardContent> - </Card> - - {/* 참여 결정 시에만 견적 작성 섹션들 표시 (단, 견적마감일이 지나지 않은 경우에만) */} - {participationDecision === true && (() => { - // 견적마감일 체크 - if (biddingDetail?.preQuoteDeadline) { - const now = new Date() - const deadline = new Date(biddingDetail.preQuoteDeadline) - const isExpired = deadline < now - - if (isExpired) { - return ( - <Card> - <CardContent className="pt-6"> - <div className="text-center py-8"> - <XCircle className="w-12 h-12 text-red-500 mx-auto mb-4" /> - <h3 className="text-lg font-semibold text-red-700 mb-2">견적 마감</h3> - <p className="text-muted-foreground"> - 견적 마감일({formatDate(biddingDetail.preQuoteDeadline, 'KR')})이 지나 견적 제출이 불가능합니다. - </p> - </div> - </CardContent> - </Card> - ) - } - } - - return true // 견적 작성 가능 - })() && ( - <> - {/* 품목별 견적 작성 섹션 */} - {prItems.length > 0 && ( - <PrItemsPricingTable - prItems={prItems} - initialQuotations={prItemQuotations} - currency={biddingDetail?.currency || 'KRW'} - onQuotationsChange={setPrItemQuotations} - onTotalAmountChange={setTotalAmount} - readOnly={false} - /> - )} - - {/* 견적 문서 업로드 섹션 */} - <SimpleFileUpload - biddingId={biddingId} - companyId={companyId} - userId={userId} - readOnly={false} - /> - - {/* 사전견적 폼 섹션 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <Send className="w-5 h-5" /> - 사전견적 제출하기 - </CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - {/* 총 금액 표시 (읽기 전용) */} - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - <div className="space-y-2"> - <Label htmlFor="totalAmount">총 사전견적 금액 <span className="text-red-500">*</span></Label> - <Input - id="totalAmount" - type="text" - value={new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: biddingDetail?.currency || 'KRW', - }).format(totalAmount)} - readOnly - className="bg-gray-50 font-semibold text-primary" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="proposedContractDeliveryDate">제안 납품일 <span className="text-red-500">*</span></Label> - <Input - id="proposedContractDeliveryDate" - type="date" - value={responseData.proposedContractDeliveryDate} - onChange={(e) => setResponseData({...responseData, proposedContractDeliveryDate: e.target.value})} - title={biddingConditions?.contractDeliveryDate ? `참고 납기일: ${formatDate(biddingConditions.contractDeliveryDate, 'KR')}` : "납품일을 선택하세요"} - /> - {biddingConditions?.contractDeliveryDate && ( - <p className="text-xs text-muted-foreground"> - 참고 납기일: {formatDate(biddingConditions.contractDeliveryDate, 'KR')} - </p> - )} - </div> - </div> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - <div className="space-y-2"> - <Label htmlFor="paymentTermsResponse">응답 지급조건 <span className="text-red-500">*</span></Label> - <Select - value={responseData.paymentTermsResponse} - onValueChange={(value) => setResponseData({...responseData, paymentTermsResponse: value})} - > - <SelectTrigger> - <SelectValue placeholder={biddingConditions?.paymentTerms ? `참고: ${biddingConditions.paymentTerms}` : "지급조건 선택"} /> - </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 className="space-y-2"> - <Label htmlFor="taxConditionsResponse">응답 세금조건 <span className="text-red-500">*</span></Label> - <Select - value={responseData.taxConditionsResponse} - onValueChange={(value) => setResponseData({...responseData, taxConditionsResponse: value})} - > - <SelectTrigger> - <SelectValue placeholder={biddingConditions?.taxConditions ? `참고: ${getTaxConditionName(biddingConditions.taxConditions)}` : "세금조건 선택"} /> - </SelectTrigger> - <SelectContent> - {TAX_CONDITIONS.map((condition) => ( - <SelectItem key={condition.code} value={condition.code}> - {condition.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - <div className="space-y-2"> - <Label htmlFor="incotermsResponse">응답 운송조건 <span className="text-red-500">*</span></Label> - <Select - value={responseData.incotermsResponse} - onValueChange={(value) => setResponseData({...responseData, incotermsResponse: value})} - > - <SelectTrigger> - <SelectValue placeholder={biddingConditions?.incoterms ? `참고: ${biddingConditions.incoterms}` : "운송조건 선택"} /> - </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 className="space-y-2"> - <Label htmlFor="proposedShippingPort">제안 선적지 <span className="text-red-500">*</span></Label> - <Select - value={responseData.proposedShippingPort} - onValueChange={(value) => setResponseData({...responseData, proposedShippingPort: value})} - > - <SelectTrigger> - <SelectValue placeholder={biddingConditions?.shippingPort ? `참고: ${biddingConditions.shippingPort}` : "선적지 선택"} /> - </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> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - <div className="space-y-2"> - <Label htmlFor="proposedDestinationPort">제안 하역지 <span className="text-red-500">*</span></Label> - <Select - value={responseData.proposedDestinationPort} - onValueChange={(value) => setResponseData({...responseData, proposedDestinationPort: value})} - > - <SelectTrigger> - <SelectValue placeholder={biddingConditions?.destinationPort ? `참고: ${biddingConditions.destinationPort}` : "하역지 선택"} /> - </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 className="space-y-2"> - <Label htmlFor="sparePartResponse">스페어파트 응답 <span className="text-red-500">*</span></Label> - <Input - id="sparePartResponse" - value={responseData.sparePartResponse} - onChange={(e) => setResponseData({...responseData, sparePartResponse: e.target.value})} - placeholder={biddingConditions?.sparePartOptions ? `참고: ${biddingConditions.sparePartOptions}` : "스페어파트 관련 응답을 입력하세요"} - /> - </div> - </div> - - <div className="space-y-2"> - <Label htmlFor="additionalProposals">변경사유</Label> - <Textarea - id="additionalProposals" - value={responseData.additionalProposals} - onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})} - placeholder="변경사유를 입력하세요" - rows={4} - /> - </div> - - <div className="space-y-4"> - <div className="flex items-center space-x-2"> - <Checkbox - id="isInitialResponse" - checked={responseData.isInitialResponse} - onCheckedChange={(checked) => - setResponseData({...responseData, isInitialResponse: !!checked}) - } - /> - <Label htmlFor="isInitialResponse">초도 공급입니다</Label> - </div> - - <div className="flex items-center space-x-2"> - <Checkbox - id="priceAdjustmentResponse" - checked={responseData.priceAdjustmentResponse} - onCheckedChange={(checked) => - setResponseData({...responseData, priceAdjustmentResponse: !!checked}) - } - /> - <Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label> - </div> - </div> - - {/* 연동제 상세 정보 (연동제 적용 시에만 표시) */} - {responseData.priceAdjustmentResponse && ( - <Card className="mt-6"> - <CardHeader> - <CardTitle className="text-lg">하도급대금등 연동표</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - <div className="space-y-2"> - <Label htmlFor="itemName">품목등의 명칭</Label> - <Input - id="itemName" - value={priceAdjustmentForm.itemName} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, itemName: e.target.value})} - placeholder="품목명을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="adjustmentReflectionPoint">조정대금 반영시점</Label> - <Input - id="adjustmentReflectionPoint" - value={priceAdjustmentForm.adjustmentReflectionPoint} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentReflectionPoint: e.target.value})} - placeholder="반영시점을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="adjustmentRatio">연동 비율 (%)</Label> - <Input - id="adjustmentRatio" - type="number" - step="0.01" - value={priceAdjustmentForm.adjustmentRatio} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentRatio: e.target.value})} - placeholder="비율을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="adjustmentPeriod">조정주기</Label> - <Input - id="adjustmentPeriod" - value={priceAdjustmentForm.adjustmentPeriod} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentPeriod: e.target.value})} - placeholder="조정주기를 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="referenceDate">기준시점</Label> - <Input - id="referenceDate" - type="date" - value={priceAdjustmentForm.referenceDate} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, referenceDate: e.target.value})} - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="comparisonDate">비교시점</Label> - <Input - id="comparisonDate" - type="date" - value={priceAdjustmentForm.comparisonDate} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, comparisonDate: e.target.value})} - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자</Label> - <Input - id="contractorWriter" - value={priceAdjustmentForm.contractorWriter} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})} - placeholder="작성자명을 입력하세요" - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="adjustmentDate">조정일</Label> - <Input - id="adjustmentDate" - type="date" - value={priceAdjustmentForm.adjustmentDate} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentDate: e.target.value})} - /> - </div> - </div> - - <div className="space-y-2"> - <Label htmlFor="majorApplicableRawMaterial">연동대상 주요 원재료</Label> - <Textarea - id="majorApplicableRawMaterial" - value={priceAdjustmentForm.majorApplicableRawMaterial} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorApplicableRawMaterial: e.target.value})} - placeholder="연동 대상 원재료를 입력하세요" - rows={3} - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="adjustmentFormula">하도급대금등 연동 산식</Label> - <Textarea - id="adjustmentFormula" - value={priceAdjustmentForm.adjustmentFormula} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentFormula: e.target.value})} - placeholder="연동 산식을 입력하세요" - rows={3} - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="rawMaterialPriceIndex">원재료 가격 기준지표</Label> - <Textarea - id="rawMaterialPriceIndex" - value={priceAdjustmentForm.rawMaterialPriceIndex} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, rawMaterialPriceIndex: e.target.value})} - placeholder="가격 기준지표를 입력하세요" - rows={2} - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="adjustmentConditions">조정요건</Label> - <Textarea - id="adjustmentConditions" - value={priceAdjustmentForm.adjustmentConditions} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentConditions: e.target.value})} - placeholder="조정요건을 입력하세요" - rows={2} - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="majorNonApplicableRawMaterial">연동 미적용 주요 원재료</Label> - <Textarea - id="majorNonApplicableRawMaterial" - value={priceAdjustmentForm.majorNonApplicableRawMaterial} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorNonApplicableRawMaterial: e.target.value})} - placeholder="연동 미적용 원재료를 입력하세요" - rows={2} - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="nonApplicableReason">연동 미적용 사유</Label> - <Textarea - id="nonApplicableReason" - value={priceAdjustmentForm.nonApplicableReason} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, nonApplicableReason: e.target.value})} - placeholder="미적용 사유를 입력하세요" - rows={2} - /> - </div> - - <div className="space-y-2"> - <Label htmlFor="priceAdjustmentNotes">기타 사항</Label> - <Textarea - id="priceAdjustmentNotes" - value={priceAdjustmentForm.notes} - onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, notes: e.target.value})} - placeholder="기타 사항을 입력하세요" - rows={2} - /> - </div> - </CardContent> - </Card> - )} - - <div className="flex justify-end gap-2 pt-4"> - <Button - variant="outline" - onClick={handleTempSave} - disabled={isSaving || isPending || (biddingDetail && !['request_for_quotation', 'received_quotation'].includes(biddingDetail.status))} - > - <Save className="w-4 h-4 mr-2" /> - {isSaving ? '저장중...' : '임시저장'} - </Button> - <Button - onClick={handleSubmitResponse} - disabled={isPending || isSaving || (biddingDetail && !['request_for_quotation', 'received_quotation'].includes(biddingDetail.status))} - > - <Send className="w-4 h-4 mr-2" /> - 사전견적 제출 - </Button> - </div> - </CardContent> - </Card> - </> - )} - </div> - ) -} |
