summaryrefslogtreecommitdiff
path: root/lib/bidding/vendor
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/vendor')
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-dialog.tsx384
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx347
-rw-r--r--lib/bidding/vendor/components/pre-quote-file-upload.tsx367
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx21
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx105
-rw-r--r--lib/bidding/vendor/partners-bidding-participation-dialog.tsx249
-rw-r--r--lib/bidding/vendor/partners-bidding-pre-quote.tsx928
-rw-r--r--lib/bidding/vendor/partners-bidding-toolbar-actions.tsx29
-rw-r--r--lib/bidding/vendor/vendor-prequote-participation-dialog.tsx268
9 files changed, 2679 insertions, 19 deletions
diff --git a/lib/bidding/vendor/components/pr-items-pricing-dialog.tsx b/lib/bidding/vendor/components/pr-items-pricing-dialog.tsx
new file mode 100644
index 00000000..ff0dfd9c
--- /dev/null
+++ b/lib/bidding/vendor/components/pr-items-pricing-dialog.tsx
@@ -0,0 +1,384 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Package,
+ FileText,
+ Download,
+ Calculator,
+ Save
+} from 'lucide-react'
+import { formatDate } from '@/lib/utils'
+import { downloadFile } from '@/lib/file-download'
+import { getSpecDocumentsForPrItem } from '../../pre-quote/service'
+import { useToast } from '@/hooks/use-toast'
+
+interface PrItem {
+ id: number
+ itemNumber: string | null
+ prNumber: string | null
+ itemInfo: string | null
+ materialDescription: string | null
+ quantity: string | null
+ quantityUnit: string | null
+ currency: string | null
+ requestedDeliveryDate: string | null
+ hasSpecDocument: boolean | null
+}
+
+interface PrItemQuotation {
+ prItemId: number
+ bidUnitPrice: number
+ bidAmount: number
+ proposedDeliveryDate?: string
+ technicalSpecification?: string
+}
+
+interface SpecDocument {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number | null
+ filePath: string
+ title: string | null
+ description: string | null
+ uploadedAt: string
+}
+
+interface PrItemsPricingDialogProps {
+ prItems: PrItem[]
+ initialQuotations?: PrItemQuotation[]
+ currency?: string
+ onSave: (quotations: PrItemQuotation[], totalAmount: number) => void
+ readOnly?: boolean
+ children: React.ReactNode
+}
+
+export function PrItemsPricingDialog({
+ prItems,
+ initialQuotations = [],
+ currency = 'KRW',
+ onSave,
+ readOnly = false,
+ children
+}: PrItemsPricingDialogProps) {
+ const { toast } = useToast()
+ const [open, setOpen] = React.useState(false)
+ const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([])
+ const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({})
+ const [loadingSpecs, setLoadingSpecs] = React.useState<Record<number, boolean>>({})
+
+ // 다이얼로그 열릴 때 초기 견적 데이터 설정
+ React.useEffect(() => {
+ if (open) {
+ const initQuotations = prItems.map(item => {
+ const existing = initialQuotations.find(q => q.prItemId === item.id)
+ if (existing) {
+ return existing
+ }
+ return {
+ prItemId: item.id,
+ bidUnitPrice: 0,
+ bidAmount: 0,
+ proposedDeliveryDate: '',
+ technicalSpecification: ''
+ }
+ })
+ setQuotations(initQuotations)
+ }
+ }, [open, prItems, initialQuotations])
+
+ // SPEC 문서 로드
+ const loadSpecDocuments = async (prItemId: number) => {
+ if (loadingSpecs[prItemId]) return
+
+ setLoadingSpecs(prev => ({ ...prev, [prItemId]: true }))
+ try {
+ const docs = await getSpecDocumentsForPrItem(prItemId)
+ // Date를 string으로 변환
+ const mappedDocs = docs.map(doc => ({
+ ...doc,
+ uploadedAt: doc.uploadedAt.toString()
+ }))
+ setSpecDocuments(prev => ({ ...prev, [prItemId]: mappedDocs }))
+ } catch (error) {
+ console.error('Failed to load spec documents:', error)
+ } finally {
+ setLoadingSpecs(prev => ({ ...prev, [prItemId]: false }))
+ }
+ }
+
+ // 견적 데이터 업데이트
+ const updateQuotation = (prItemId: number, field: keyof PrItemQuotation, value: any) => {
+ const updatedQuotations = quotations.map(q => {
+ if (q.prItemId === prItemId) {
+ const updated = { ...q, [field]: value }
+
+ // 단가가 변경되면 금액 자동 계산
+ if (field === 'bidUnitPrice') {
+ const prItem = prItems.find(item => item.id === prItemId)
+ const quantity = parseFloat(prItem?.quantity || '1')
+ updated.bidAmount = updated.bidUnitPrice * quantity
+ }
+
+ return updated
+ }
+ return q
+ })
+
+ setQuotations(updatedQuotations)
+ }
+
+ // 파일 다운로드
+ const handleDownloadSpec = async (document: SpecDocument) => {
+ try {
+ await downloadFile(document.filePath, document.originalFileName, {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Failed to download spec document:', error)
+ }
+ }
+
+ // 저장 처리
+ const handleSave = () => {
+ const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0)
+ onSave(quotations, totalAmount)
+ setOpen(false)
+ toast({
+ title: '저장 완료',
+ description: '품목별 견적이 저장되었습니다.',
+ })
+ }
+
+ // 통화 포맷팅
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ }).format(amount)
+ }
+
+ // 총 금액 계산
+ const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0)
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ {children}
+ </DialogTrigger>
+ <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ 품목별 견적 작성
+ </DialogTitle>
+ <DialogDescription>
+ 각 품목별로 견적 단가를 입력하여 총 사전견적 금액을 계산합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="overflow-x-auto">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>아이템번호</TableHead>
+ <TableHead>PR번호</TableHead>
+ <TableHead>품목정보</TableHead>
+ <TableHead>자재내역</TableHead>
+ <TableHead>수량</TableHead>
+ <TableHead>단위</TableHead>
+ <TableHead>견적단가</TableHead>
+ <TableHead>견적금액</TableHead>
+ <TableHead>납품예정일</TableHead>
+ <TableHead>기술사양</TableHead>
+ <TableHead>SPEC</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {prItems.map((item) => {
+ const quotation = quotations.find(q => q.prItemId === item.id) || {
+ prItemId: item.id,
+ bidUnitPrice: 0,
+ bidAmount: 0,
+ proposedDeliveryDate: '',
+ technicalSpecification: ''
+ }
+
+ return (
+ <TableRow key={item.id}>
+ <TableCell className="font-medium">
+ {item.itemNumber || '-'}
+ </TableCell>
+ <TableCell>{item.prNumber || '-'}</TableCell>
+ <TableCell>
+ <div className="max-w-32 truncate" title={item.itemInfo || ''}>
+ {item.itemInfo || '-'}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="max-w-32 truncate" title={item.materialDescription || ''}>
+ {item.materialDescription || '-'}
+ </div>
+ </TableCell>
+ <TableCell className="text-right">
+ {item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'}
+ </TableCell>
+ <TableCell>{item.quantityUnit || '-'}</TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="font-medium">
+ {quotation.bidUnitPrice.toLocaleString()}
+ </span>
+ ) : (
+ <Input
+ type="number"
+ value={quotation.bidUnitPrice}
+ onChange={(e) => updateQuotation(
+ item.id,
+ 'bidUnitPrice',
+ parseFloat(e.target.value) || 0
+ )}
+ className="w-32 text-right"
+ placeholder="단가"
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ <div className="font-semibold text-primary">
+ {formatCurrency(quotation.bidAmount)}
+ </div>
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ quotation.proposedDeliveryDate ?
+ formatDate(quotation.proposedDeliveryDate, 'KR') : '-'
+ ) : (
+ <Input
+ type="date"
+ value={quotation.proposedDeliveryDate}
+ onChange={(e) => updateQuotation(
+ item.id,
+ 'proposedDeliveryDate',
+ e.target.value
+ )}
+ className="w-40"
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <div className="max-w-32 truncate" title={quotation.technicalSpecification || ''}>
+ {quotation.technicalSpecification || '-'}
+ </div>
+ ) : (
+ <Textarea
+ value={quotation.technicalSpecification}
+ onChange={(e) => updateQuotation(
+ item.id,
+ 'technicalSpecification',
+ e.target.value
+ )}
+ placeholder="기술사양 입력"
+ className="w-48 min-h-[60px]"
+ rows={2}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {item.hasSpecDocument ? (
+ <div className="space-y-1">
+ {!specDocuments[item.id] ? (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => loadSpecDocuments(item.id)}
+ disabled={loadingSpecs[item.id]}
+ >
+ <FileText className="w-3 h-3 mr-1" />
+ {loadingSpecs[item.id] ? '로딩...' : 'SPEC 보기'}
+ </Button>
+ ) : specDocuments[item.id].length > 0 ? (
+ <div className="space-y-1">
+ {specDocuments[item.id].map((doc) => (
+ <Button
+ key={doc.id}
+ variant="outline"
+ size="sm"
+ onClick={() => handleDownloadSpec(doc)}
+ className="block text-xs"
+ >
+ <Download className="w-3 h-3 mr-1" />
+ {doc.originalFileName}
+ </Button>
+ ))}
+ </div>
+ ) : (
+ <Badge variant="secondary">문서 없음</Badge>
+ )}
+ </div>
+ ) : (
+ <Badge variant="outline">SPEC 없음</Badge>
+ )}
+ </TableCell>
+ </TableRow>
+ )
+ })}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 총 금액 표시 */}
+ <div className="flex justify-end border-t pt-4">
+ <div className="bg-gray-50 rounded-lg p-4 min-w-80">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Calculator className="w-5 h-5 text-primary" />
+ <Label className="font-semibold text-lg">총 사전견적 금액</Label>
+ </div>
+ <div className="text-2xl font-bold text-primary">
+ {formatCurrency(totalAmount)}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ 취소
+ </Button>
+ {!readOnly && (
+ <Button onClick={handleSave}>
+ <Save className="w-4 h-4 mr-2" />
+ 저장하기
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
new file mode 100644
index 00000000..320ed6eb
--- /dev/null
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -0,0 +1,347 @@
+'use client'
+
+import * as React from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Package,
+ FileText,
+ Download,
+ Calculator
+} from 'lucide-react'
+import { formatDate } from '@/lib/utils'
+import { downloadFile } from '@/lib/file-download'
+import { getSpecDocumentsForPrItem } from '../../pre-quote/service'
+
+interface PrItem {
+ id: number
+ itemNumber: string | null
+ prNumber: string | null
+ itemInfo: string | null
+ materialDescription: string | null
+ quantity: string | null
+ quantityUnit: string | null
+ currency: string | null
+ requestedDeliveryDate: string | null
+ hasSpecDocument: boolean | null
+}
+
+interface PrItemQuotation {
+ prItemId: number
+ bidUnitPrice: number
+ bidAmount: number
+ proposedDeliveryDate?: string
+ technicalSpecification?: string
+}
+
+interface SpecDocument {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number | null
+ filePath: string
+ title: string | null
+ description: string | null
+ uploadedAt: string
+}
+
+interface PrItemsPricingTableProps {
+ prItems: PrItem[]
+ initialQuotations?: PrItemQuotation[]
+ currency?: string
+ onQuotationsChange: (quotations: PrItemQuotation[]) => void
+ onTotalAmountChange: (total: number) => void
+ readOnly?: boolean
+}
+
+export function PrItemsPricingTable({
+ prItems,
+ initialQuotations = [],
+ currency = 'KRW',
+ onQuotationsChange,
+ onTotalAmountChange,
+ readOnly = false
+}: PrItemsPricingTableProps) {
+ const [quotations, setQuotations] = React.useState<PrItemQuotation[]>([])
+ const [specDocuments, setSpecDocuments] = React.useState<Record<number, SpecDocument[]>>({})
+ const [loadingSpecs, setLoadingSpecs] = React.useState<Record<number, boolean>>({})
+
+ // 초기 견적 데이터 설정
+ React.useEffect(() => {
+ const initQuotations = prItems.map(item => {
+ const existing = initialQuotations.find(q => q.prItemId === item.id)
+ if (existing) {
+ return existing
+ }
+ return {
+ prItemId: item.id,
+ bidUnitPrice: 0,
+ bidAmount: 0,
+ proposedDeliveryDate: '',
+ technicalSpecification: ''
+ }
+ })
+ setQuotations(initQuotations)
+ }, [prItems, initialQuotations])
+
+ // SPEC 문서 로드
+ const loadSpecDocuments = async (prItemId: number) => {
+ if (loadingSpecs[prItemId]) return
+
+ setLoadingSpecs(prev => ({ ...prev, [prItemId]: true }))
+ try {
+ const docs = await getSpecDocumentsForPrItem(prItemId)
+ // Date를 string으로 변환
+ const mappedDocs = docs.map(doc => ({
+ ...doc,
+ uploadedAt: doc.uploadedAt.toString()
+ }))
+ setSpecDocuments(prev => ({ ...prev, [prItemId]: mappedDocs }))
+ } catch (error) {
+ console.error('Failed to load spec documents:', error)
+ } finally {
+ setLoadingSpecs(prev => ({ ...prev, [prItemId]: false }))
+ }
+ }
+
+ // 견적 데이터 업데이트
+ const updateQuotation = (prItemId: number, field: keyof PrItemQuotation, value: any) => {
+ const updatedQuotations = quotations.map(q => {
+ if (q.prItemId === prItemId) {
+ const updated = { ...q, [field]: value }
+
+ // 단가나 수량이 변경되면 금액 자동 계산
+ if (field === 'bidUnitPrice') {
+ const prItem = prItems.find(item => item.id === prItemId)
+ const quantity = parseFloat(prItem?.quantity || '1')
+ updated.bidAmount = updated.bidUnitPrice * quantity
+ }
+
+ return updated
+ }
+ return q
+ })
+
+ setQuotations(updatedQuotations)
+ onQuotationsChange(updatedQuotations)
+
+ // 총 금액 계산
+ const totalAmount = updatedQuotations.reduce((sum, q) => sum + q.bidAmount, 0)
+ onTotalAmountChange(totalAmount)
+ }
+
+ // 파일 다운로드
+ const handleDownloadSpec = async (document: SpecDocument) => {
+ try {
+ await downloadFile(document.filePath, document.originalFileName, {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Failed to download spec document:', error)
+ }
+ }
+
+ // 통화 포맷팅
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ }).format(amount)
+ }
+
+ // 총 금액 계산
+ const totalAmount = quotations.reduce((sum, q) => sum + q.bidAmount, 0)
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ 품목별 견적 작성
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ <div className="overflow-x-auto">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>아이템번호</TableHead>
+ <TableHead>PR번호</TableHead>
+ <TableHead>품목정보</TableHead>
+ <TableHead>자재내역</TableHead>
+ <TableHead>수량</TableHead>
+ <TableHead>단위</TableHead>
+ <TableHead>견적단가</TableHead>
+ <TableHead>견적금액</TableHead>
+ <TableHead>납품예정일</TableHead>
+ <TableHead>기술사양</TableHead>
+ <TableHead>SPEC</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {prItems.map((item) => {
+ const quotation = quotations.find(q => q.prItemId === item.id) || {
+ prItemId: item.id,
+ bidUnitPrice: 0,
+ bidAmount: 0,
+ proposedDeliveryDate: '',
+ technicalSpecification: ''
+ }
+
+ return (
+ <TableRow key={item.id}>
+ <TableCell className="font-medium">
+ {item.itemNumber || '-'}
+ </TableCell>
+ <TableCell>{item.prNumber || '-'}</TableCell>
+ <TableCell>
+ <div className="max-w-32 truncate" title={item.itemInfo || ''}>
+ {item.itemInfo || '-'}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="max-w-32 truncate" title={item.materialDescription || ''}>
+ {item.materialDescription || '-'}
+ </div>
+ </TableCell>
+ <TableCell className="text-right">
+ {item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'}
+ </TableCell>
+ <TableCell>{item.quantityUnit || '-'}</TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="font-medium">
+ {quotation.bidUnitPrice.toLocaleString()}
+ </span>
+ ) : (
+ <Input
+ type="number"
+ value={quotation.bidUnitPrice}
+ onChange={(e) => updateQuotation(
+ item.id,
+ 'bidUnitPrice',
+ parseFloat(e.target.value) || 0
+ )}
+ className="w-32 text-right"
+ placeholder="단가"
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ <div className="font-semibold text-primary">
+ {formatCurrency(quotation.bidAmount)}
+ </div>
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ quotation.proposedDeliveryDate ?
+ formatDate(quotation.proposedDeliveryDate, 'KR') : '-'
+ ) : (
+ <Input
+ type="date"
+ value={quotation.proposedDeliveryDate}
+ onChange={(e) => updateQuotation(
+ item.id,
+ 'proposedDeliveryDate',
+ e.target.value
+ )}
+ className="w-40"
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <div className="max-w-32 truncate" title={quotation.technicalSpecification || ''}>
+ {quotation.technicalSpecification || '-'}
+ </div>
+ ) : (
+ <Textarea
+ value={quotation.technicalSpecification}
+ onChange={(e) => updateQuotation(
+ item.id,
+ 'technicalSpecification',
+ e.target.value
+ )}
+ placeholder="기술사양 입력"
+ className="w-48 min-h-[60px]"
+ rows={2}
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {item.hasSpecDocument ? (
+ <div className="space-y-1">
+ {!specDocuments[item.id] ? (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => loadSpecDocuments(item.id)}
+ disabled={loadingSpecs[item.id]}
+ >
+ <FileText className="w-3 h-3 mr-1" />
+ {loadingSpecs[item.id] ? '로딩...' : 'SPEC 보기'}
+ </Button>
+ ) : specDocuments[item.id].length > 0 ? (
+ <div className="space-y-1">
+ {specDocuments[item.id].map((doc) => (
+ <Button
+ key={doc.id}
+ variant="outline"
+ size="sm"
+ onClick={() => handleDownloadSpec(doc)}
+ className="block text-xs"
+ >
+ <Download className="w-3 h-3 mr-1" />
+ {doc.originalFileName}
+ </Button>
+ ))}
+ </div>
+ ) : (
+ <Badge variant="secondary">문서 없음</Badge>
+ )}
+ </div>
+ ) : (
+ <Badge variant="outline">SPEC 없음</Badge>
+ )}
+ </TableCell>
+ </TableRow>
+ )
+ })}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 총 금액 표시 */}
+ <div className="flex justify-end">
+ <Card className="w-80">
+ <CardContent className="pt-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Calculator className="w-4 h-4" />
+ <Label className="font-semibold">총 사전견적 금액</Label>
+ </div>
+ <div className="text-2xl font-bold text-primary">
+ {formatCurrency(totalAmount)}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/lib/bidding/vendor/components/pre-quote-file-upload.tsx b/lib/bidding/vendor/components/pre-quote-file-upload.tsx
new file mode 100644
index 00000000..b6d8990b
--- /dev/null
+++ b/lib/bidding/vendor/components/pre-quote-file-upload.tsx
@@ -0,0 +1,367 @@
+'use client'
+
+import * as React from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Badge } from '@/components/ui/badge'
+import { Progress } from '@/components/ui/progress'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Upload,
+ FileText,
+ Download,
+ Trash2,
+ AlertCircle
+} from 'lucide-react'
+import { useToast } from '@/hooks/use-toast'
+import { saveFile } from '@/lib/file-stroage'
+import { downloadFile } from '@/lib/file-download'
+import {
+ uploadPreQuoteDocument,
+ getPreQuoteDocuments
+} from '../../pre-quote/service'
+
+interface UploadedDocument {
+ id: number
+ fileName: string
+ originalFileName: string
+ fileSize: number | null
+ filePath: string
+ title: string | null
+ description: string | null
+ uploadedAt: string
+}
+
+interface PreQuoteFileUploadProps {
+ biddingId: number
+ companyId: number
+ onUploadComplete?: (documentId: number) => void
+ readOnly?: boolean
+}
+
+export function PreQuoteFileUpload({
+ biddingId,
+ companyId,
+ onUploadComplete,
+ readOnly = false
+}: PreQuoteFileUploadProps) {
+ const { toast } = useToast()
+ const [documents, setDocuments] = React.useState<UploadedDocument[]>([])
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [uploadProgress, setUploadProgress] = React.useState(0)
+ const [dragActive, setDragActive] = React.useState(false)
+
+ // 업로드된 문서 목록 로드
+ const loadDocuments = React.useCallback(async () => {
+ try {
+ const docs = await getPreQuoteDocuments(biddingId, companyId)
+ // Date를 string으로 변환
+ const mappedDocs = docs.map(doc => ({
+ ...doc,
+ uploadedAt: doc.uploadedAt.toString()
+ }))
+ setDocuments(mappedDocs)
+ } catch (error) {
+ console.error('Failed to load documents:', error)
+ toast({
+ title: '오류',
+ description: '업로드된 문서 목록을 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }, [biddingId, companyId, toast])
+
+ React.useEffect(() => {
+ loadDocuments()
+ }, [loadDocuments])
+
+ // 파일 업로드 처리
+ const handleFileUpload = async (files: FileList | File[]) => {
+ if (readOnly) return
+
+ const fileArray = Array.from(files)
+ if (fileArray.length === 0) return
+
+ setIsUploading(true)
+ setUploadProgress(0)
+
+ try {
+ for (let i = 0; i < fileArray.length; i++) {
+ const file = fileArray[i]
+
+ // 파일 크기 체크 (50MB 제한)
+ if (file.size > 50 * 1024 * 1024) {
+ toast({
+ title: '파일 크기 초과',
+ description: `${file.name}의 크기가 50MB를 초과합니다.`,
+ variant: 'destructive',
+ })
+ continue
+ }
+
+ // 파일 타입 체크
+ const allowedTypes = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'image/jpeg',
+ 'image/png',
+ 'application/zip'
+ ]
+
+ if (!allowedTypes.includes(file.type)) {
+ toast({
+ title: '지원하지 않는 파일 형식',
+ description: `${file.name}: PDF, Word, Excel, 이미지, ZIP 파일만 업로드 가능합니다.`,
+ variant: 'destructive',
+ })
+ continue
+ }
+
+ // 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: `bidding/${biddingId}/quotations`,
+ originalName: file.name,
+ userId: 'current-user' // TODO: 실제 사용자 ID
+ })
+
+ if (!saveResult.success) {
+ toast({
+ title: '업로드 실패',
+ description: `${file.name}: ${saveResult.error}`,
+ variant: 'destructive',
+ })
+ continue
+ }
+
+ // 데이터베이스에 문서 정보 저장
+ const uploadResult = await uploadPreQuoteDocument(
+ biddingId,
+ companyId,
+ {
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type,
+ filePath: saveResult.path!
+ },
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (uploadResult.success) {
+ toast({
+ title: '업로드 완료',
+ description: `${file.name}이 성공적으로 업로드되었습니다.`,
+ })
+
+ if (onUploadComplete && uploadResult.documentId) {
+ onUploadComplete(uploadResult.documentId)
+ }
+ } else {
+ toast({
+ title: '업로드 실패',
+ description: uploadResult.error,
+ variant: 'destructive',
+ })
+ }
+
+ // 진행률 업데이트
+ setUploadProgress(((i + 1) / fileArray.length) * 100)
+ }
+
+ // 문서 목록 새로고침
+ await loadDocuments()
+
+ } catch (error) {
+ console.error('Upload error:', error)
+ toast({
+ title: '업로드 오류',
+ description: '파일 업로드 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsUploading(false)
+ setUploadProgress(0)
+ }
+ }
+
+ // 드래그 앤 드롭 처리
+ const handleDrag = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ if (e.type === 'dragenter' || e.type === 'dragover') {
+ setDragActive(true)
+ } else if (e.type === 'dragleave') {
+ setDragActive(false)
+ }
+ }
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setDragActive(false)
+
+ if (readOnly) return
+
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
+ handleFileUpload(e.dataTransfer.files)
+ }
+ }
+
+ // 파일 다운로드
+ const handleDownload = async (document: UploadedDocument) => {
+ try {
+ await downloadFile(document.filePath, document.originalFileName, {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Failed to download document:', error)
+ toast({
+ title: '다운로드 실패',
+ description: '파일 다운로드에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ // 파일 크기 포맷팅
+ 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]
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 견적 문서 업로드
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {!readOnly && (
+ <div
+ className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
+ dragActive
+ ? 'border-primary bg-primary/5'
+ : 'border-gray-300 hover:border-gray-400'
+ }`}
+ onDragEnter={handleDrag}
+ onDragLeave={handleDrag}
+ onDragOver={handleDrag}
+ onDrop={handleDrop}
+ >
+ <Upload className="w-8 h-8 mx-auto text-gray-400 mb-2" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그하여 업로드하거나 클릭하여 선택하세요
+ </p>
+ <Input
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.zip"
+ onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
+ className="hidden"
+ id="file-upload"
+ />
+ <Label htmlFor="file-upload">
+ <Button variant="outline" className="cursor-pointer" asChild>
+ <span>파일 선택</span>
+ </Button>
+ </Label>
+ </div>
+ <p className="text-xs text-gray-500 mt-2">
+ 지원 형식: PDF, Word, Excel, 이미지, ZIP (최대 50MB)
+ </p>
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <div className="flex items-center gap-2">
+ <Upload className="w-4 h-4 animate-pulse" />
+ <span className="text-sm">업로드 중...</span>
+ </div>
+ <Progress value={uploadProgress} className="h-2" />
+ </div>
+ )}
+
+ {/* 업로드된 문서 목록 */}
+ {documents.length > 0 ? (
+ <div className="space-y-2">
+ <Label className="text-sm font-medium">업로드된 문서</Label>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>파일명</TableHead>
+ <TableHead>크기</TableHead>
+ <TableHead>업로드일</TableHead>
+ <TableHead className="w-24">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {documents.map((doc) => (
+ <TableRow key={doc.id}>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <FileText className="w-4 h-4 text-gray-500" />
+ <span className="truncate max-w-48" title={doc.originalFileName}>
+ {doc.originalFileName}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell className="text-sm text-gray-500">
+ {formatFileSize(doc.fileSize)}
+ </TableCell>
+ <TableCell className="text-sm text-gray-500">
+ {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
+ </TableCell>
+ <TableCell>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleDownload(doc)}
+ >
+ <Download className="w-3 h-3" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ ) : (
+ <div className="text-center py-4 text-gray-500">
+ <FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
+ <p className="text-sm">업로드된 문서가 없습니다</p>
+ </div>
+ )}
+
+ {readOnly && documents.length === 0 && (
+ <div className="flex items-center gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
+ <AlertCircle className="w-4 h-4 text-yellow-600" />
+ <span className="text-sm text-yellow-800">
+ 견적 문서가 업로드되지 않았습니다.
+ </span>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 41cc329f..04575550 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -17,7 +17,8 @@ import {
FileText,
MoreHorizontal,
Calendar,
- User
+ User,
+ Calculator
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
@@ -113,8 +114,20 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
}
}
+ const handlePreQuote = () => {
+ if (setRowAction) {
+ setRowAction({
+ type: 'pre-quote',
+ row: { original: row.original }
+ })
+ }
+ }
+
const canManageAttendance = row.original.invitationStatus === 'sent' ||
row.original.invitationStatus === 'accepted'
+
+ // 사전견적이 가능한 조건: 초대 발송(sent) 상태인 경우
+ const canDoPreQuote = row.original.invitationStatus === 'sent'
return (
<DropdownMenu>
@@ -132,6 +145,12 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
<FileText className="mr-2 h-4 w-4" />
상세보기
</DropdownMenuItem>
+ {canDoPreQuote && (
+ <DropdownMenuItem onClick={handlePreQuote}>
+ <Calculator className="mr-2 h-4 w-4" />
+ 사전견적하기
+ </DropdownMenuItem>
+ )}
{canManageAttendance && (
<DropdownMenuItem onClick={handleAttendance}>
<Users className="mr-2 h-4 w-4" />
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx
index aa185c3a..a13334ef 100644
--- a/lib/bidding/vendor/partners-bidding-list.tsx
+++ b/lib/bidding/vendor/partners-bidding-list.tsx
@@ -15,6 +15,9 @@ import { getPartnersBiddingListColumns } from './partners-bidding-list-columns'
import { getBiddingListForPartners, PartnersBiddingListItem } from '../detail/service'
import { PartnersBiddingToolbarActions } from './partners-bidding-toolbar-actions'
import { PartnersBiddingAttendanceDialog } from './partners-bidding-attendance-dialog'
+import { PartnersBiddingParticipationDialog } from './partners-bidding-participation-dialog'
+import { VendorPreQuoteParticipationDialog } from './vendor-prequote-participation-dialog'
+import { setPreQuoteParticipation, getBiddingCompaniesForPartners } from '../pre-quote/service'
interface PartnersBiddingListProps {
companyId: number
@@ -24,10 +27,59 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
const [data, setData] = React.useState<PartnersBiddingListItem[]>([])
const [pageCount, setPageCount] = React.useState<number>(1)
const [isLoading, setIsLoading] = React.useState(true)
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<PartnersBiddingListItem> | null>(null)
+ const [rowAction, setRowAction] = React.useState<{ type: string; row: { original: PartnersBiddingListItem } } | null>(null)
+ const [isParticipationDialogOpen, setIsParticipationDialogOpen] = React.useState(false)
+ const [selectedBiddingForParticipation, setSelectedBiddingForParticipation] = React.useState<PartnersBiddingListItem | null>(null)
+ const [isPreQuoteParticipationDialogOpen, setIsPreQuoteParticipationDialogOpen] = React.useState(false)
+ const [selectedBiddingForPreQuoteParticipation, setSelectedBiddingForPreQuoteParticipation] = React.useState<any | null>(null)
const router = useRouter()
+ // 데이터 새로고침 함수
+ const refreshData = React.useCallback(async () => {
+ try {
+ setIsLoading(true)
+ const result = await getBiddingListForPartners(companyId)
+ setData(result)
+ } catch (error) {
+ console.error('Failed to refresh bidding list:', error)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [companyId])
+
+ // 사전견적 참여의사 결정을 위한 상세 데이터 로드
+ const loadBiddingDetailForParticipation = React.useCallback(async (bidding: PartnersBiddingListItem) => {
+ try {
+ const biddingDetail = await getBiddingCompaniesForPartners(bidding.biddingId, companyId)
+ if (biddingDetail) {
+ setSelectedBiddingForPreQuoteParticipation(biddingDetail)
+ setIsPreQuoteParticipationDialogOpen(true)
+ }
+ } catch (error) {
+ console.error('Failed to load bidding detail for participation:', error)
+ }
+ }, [companyId])
+
+ // 사전견적 참여의사 결정 핸들러
+ const handlePreQuoteParticipationDecision = React.useCallback(async (participate: boolean) => {
+ if (!selectedBiddingForPreQuoteParticipation?.biddingCompanyId) {
+ throw new Error('업체 정보를 찾을 수 없습니다.')
+ }
+
+ const result = await setPreQuoteParticipation(
+ selectedBiddingForPreQuoteParticipation.biddingCompanyId,
+ participate,
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success) {
+ await refreshData() // 데이터 새로고침
+ } else {
+ throw new Error(result.error)
+ }
+ }, [selectedBiddingForPreQuoteParticipation?.biddingCompanyId, refreshData])
+
// 데이터 로드
React.useEffect(() => {
const loadData = async () => {
@@ -47,7 +99,7 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
loadData()
}, [companyId])
- // rowAction 변경 감지하여 해당 페이지로 이동
+ // rowAction 변경 감지하여 해당 페이지로 이동 또는 다이얼로그 열기
React.useEffect(() => {
if (rowAction) {
switch (rowAction.type) {
@@ -55,11 +107,20 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
// 상세 페이지로 이동 (biddingId 사용)
router.push(`/partners/bid/${rowAction.row.original.biddingId}`)
break
+ case 'pre-quote':
+ // 사전견적 페이지로 이동
+ router.push(`/partners/bid/${rowAction.row.original.biddingId}/pre-quote`)
+ break
+ case 'participation':
+ // 사전견적 참여 의사 결정 다이얼로그 열기 - 상세 데이터 로드 필요
+ loadBiddingDetailForParticipation(rowAction.row.original)
+ setRowAction(null) // rowAction 초기화
+ break
default:
break
}
}
- }, [rowAction, router])
+ }, [rowAction, router, loadBiddingDetailForParticipation])
const columns = React.useMemo(
() => getPartnersBiddingListColumns({ setRowAction }),
@@ -135,19 +196,6 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
clearOnDefault: true,
})
- // 데이터 새로고침 함수
- const refreshData = React.useCallback(async () => {
- try {
- setIsLoading(true)
- const result = await getBiddingListForPartners(companyId)
- setData(result)
- } catch (error) {
- console.error('Failed to refresh bidding list:', error)
- } finally {
- setIsLoading(false)
- }
- }, [companyId])
-
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
@@ -167,7 +215,7 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
filterFields={advancedFilterFields}
shallow={false}
>
- <PartnersBiddingToolbarActions table={table} onRefresh={refreshData} setRowAction={setRowAction} />
+ <PartnersBiddingToolbarActions table={table} companyId={companyId} onRefresh={refreshData} setRowAction={setRowAction} />
</DataTableAdvancedToolbar>
</DataTable>
@@ -186,6 +234,29 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
isAttending={rowAction?.row.original?.isAttendingMeeting || null}
onSuccess={refreshData}
/>
+
+ <PartnersBiddingParticipationDialog
+ open={isParticipationDialogOpen}
+ onOpenChange={setIsParticipationDialogOpen}
+ bidding={selectedBiddingForParticipation}
+ companyId={companyId}
+ onSuccess={() => {
+ refreshData()
+ setSelectedBiddingForParticipation(null)
+ }}
+ />
+
+ <VendorPreQuoteParticipationDialog
+ open={isPreQuoteParticipationDialogOpen}
+ onOpenChange={(open) => {
+ setIsPreQuoteParticipationDialogOpen(open)
+ if (!open) {
+ setSelectedBiddingForPreQuoteParticipation(null)
+ }
+ }}
+ biddingDetail={selectedBiddingForPreQuoteParticipation}
+ onParticipationDecision={handlePreQuoteParticipationDecision}
+ />
</>
)
}
diff --git a/lib/bidding/vendor/partners-bidding-participation-dialog.tsx b/lib/bidding/vendor/partners-bidding-participation-dialog.tsx
new file mode 100644
index 00000000..8d6fbeea
--- /dev/null
+++ b/lib/bidding/vendor/partners-bidding-participation-dialog.tsx
@@ -0,0 +1,249 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { CheckCircle, XCircle, AlertCircle, Calendar, Package } from 'lucide-react'
+import { PartnersBiddingListItem } from '../detail/service'
+import { respondToPreQuoteInvitation, getBiddingCompaniesForPartners } from '../pre-quote/service'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+import { formatDate } from '@/lib/utils'
+
+interface PartnersBiddingParticipationDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ bidding: PartnersBiddingListItem | null
+ companyId: number
+ onSuccess: () => void
+}
+
+export function PartnersBiddingParticipationDialog({
+ open,
+ onOpenChange,
+ bidding,
+ companyId,
+ onSuccess
+}: PartnersBiddingParticipationDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedResponse, setSelectedResponse] = React.useState<'accepted' | 'declined' | null>(null)
+
+ const handleSubmit = () => {
+ if (!bidding || !selectedResponse) {
+ toast({
+ title: '오류',
+ description: '참여 의사를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ // 먼저 해당 업체의 biddingCompanyId를 조회
+ const biddingCompanyData = await getBiddingCompaniesForPartners(bidding.biddingId, companyId)
+
+ if (!biddingCompanyData || !biddingCompanyData.biddingCompanyId) {
+ toast({
+ title: '오류',
+ description: '입찰 업체 정보를 찾을 수 없습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ const result = await respondToPreQuoteInvitation(
+ biddingCompanyData.biddingCompanyId,
+ selectedResponse,
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: result.message,
+ })
+ setSelectedResponse(null)
+ onOpenChange(false)
+ onSuccess()
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '처리 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ onOpenChange(open)
+ if (!open) {
+ setSelectedResponse(null)
+ }
+ }
+
+ if (!bidding) return null
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <AlertCircle className="w-5 h-5" />
+ 사전견적 참여 의사 결정
+ </DialogTitle>
+ <DialogDescription>
+ 아래 입찰건에 대한 사전견적 참여 여부를 결정해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="py-4">
+ {/* 입찰 정보 카드 */}
+ <Card className="mb-6">
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ 입찰 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-3">
+ <div>
+ <strong>입찰번호:</strong> {bidding.biddingNumber}
+ {bidding.revision > 0 && (
+ <Badge variant="outline" className="ml-2">
+ Rev.{bidding.revision}
+ </Badge>
+ )}
+ </div>
+ <div>
+ <strong>입찰명:</strong> {bidding.title}
+ </div>
+ <div>
+ <strong>품목명:</strong> {bidding.itemName}
+ </div>
+ <div>
+ <strong>프로젝트:</strong> {bidding.projectName}
+ </div>
+ {bidding.preQuoteDate && (
+ <div className="flex items-center gap-2">
+ <Calendar className="w-4 h-4" />
+ <strong>사전견적 마감일:</strong>
+ <span className="text-red-600 font-semibold">
+ {formatDate(bidding.preQuoteDate, 'KR')}
+ </span>
+ </div>
+ )}
+ <div>
+ <strong>담당자:</strong> {bidding.managerName}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 참여 의사 선택 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">참여 의사를 선택해주세요:</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ {/* 참여 수락 */}
+ <Card
+ className={`cursor-pointer transition-all ${
+ selectedResponse === 'accepted'
+ ? 'ring-2 ring-green-500 bg-green-50'
+ : 'hover:shadow-md'
+ }`}
+ onClick={() => setSelectedResponse('accepted')}
+ >
+ <CardContent className="p-6 text-center">
+ <CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-4" />
+ <h4 className="text-lg font-semibold text-green-700 mb-2">
+ 참여 수락
+ </h4>
+ <p className="text-sm text-gray-600">
+ 사전견적에 참여하겠습니다.
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 참여 거절 */}
+ <Card
+ className={`cursor-pointer transition-all ${
+ selectedResponse === 'declined'
+ ? 'ring-2 ring-red-500 bg-red-50'
+ : 'hover:shadow-md'
+ }`}
+ onClick={() => setSelectedResponse('declined')}
+ >
+ <CardContent className="p-6 text-center">
+ <XCircle className="w-12 h-12 text-red-600 mx-auto mb-4" />
+ <h4 className="text-lg font-semibold text-red-700 mb-2">
+ 참여 거절
+ </h4>
+ <p className="text-sm text-gray-600">
+ 사전견적에 참여하지 않겠습니다.
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+
+ {selectedResponse && (
+ <div className="mt-4 p-4 rounded-lg bg-blue-50 border border-blue-200">
+ <div className="flex items-center gap-2">
+ <AlertCircle className="w-5 h-5 text-blue-600" />
+ <span className="font-medium text-blue-800">
+ {selectedResponse === 'accepted'
+ ? '참여 수락을 선택하셨습니다.'
+ : '참여 거절을 선택하셨습니다.'
+ }
+ </span>
+ </div>
+ <p className="text-sm text-blue-600 mt-1">
+ {selectedResponse === 'accepted'
+ ? '수락 후 사전견적서를 작성하실 수 있습니다.'
+ : '거절 후에는 이 입찰건에 참여할 수 없습니다.'
+ }
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => handleOpenChange(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isPending || !selectedResponse}
+ className={selectedResponse === 'accepted' ? 'bg-green-600 hover:bg-green-700' :
+ selectedResponse === 'declined' ? 'bg-red-600 hover:bg-red-700' : ''}
+ >
+ {selectedResponse === 'accepted' && <CheckCircle className="w-4 h-4 mr-2" />}
+ {selectedResponse === 'declined' && <XCircle className="w-4 h-4 mr-2" />}
+ {selectedResponse === 'accepted' ? '참여 수락' :
+ selectedResponse === 'declined' ? '참여 거절' : '선택하세요'}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
new file mode 100644
index 00000000..d5ff3fd6
--- /dev/null
+++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
@@ -0,0 +1,928 @@
+'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 {
+ ArrowLeft,
+ Calendar,
+ Building2,
+ Package,
+ User,
+ DollarSign,
+ FileText,
+ Users,
+ Send,
+ CheckCircle,
+ XCircle,
+ Save
+} from 'lucide-react'
+
+import { formatDate } from '@/lib/utils'
+import {
+ getBiddingCompaniesForPartners,
+ submitPreQuoteResponse,
+ getPrItemsForBidding,
+ getSavedPrItemQuotations,
+ savePreQuoteDraft
+} from '../pre-quote/service'
+import { getBiddingConditions } from '../service'
+import { PrItemsPricingTable } from './components/pr-items-pricing-table'
+import { PreQuoteFileUpload } from './components/pre-quote-file-upload'
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+ biddingTypeLabels
+} from '@/db/schema'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from '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
+ contractPeriod: string | 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
+ 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 [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [biddingConditions, setBiddingConditions] = React.useState<any | null>(null)
+
+ // 품목별 견적 관련 상태
+ 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 [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({
+ itemName: '',
+ adjustmentReflectionPoint: '',
+ majorApplicableRawMaterial: '',
+ adjustmentFormula: '',
+ rawMaterialPriceIndex: '',
+ referenceDate: '',
+ comparisonDate: '',
+ adjustmentRatio: '',
+ notes: '',
+ adjustmentConditions: '',
+ majorNonApplicableRawMaterial: '',
+ adjustmentPeriod: '',
+ contractorWriter: '',
+ adjustmentDate: '',
+ nonApplicableReason: '',
+ })
+
+ // 데이터 로드
+ 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)
+ }
+
+ // 기존 응답 데이터로 폼 초기화
+ 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,
+ })
+ }
+
+ if (conditions) {
+ // BiddingConditionsEdit와 같은 방식으로 raw 데이터 사용
+ setBiddingConditions(conditions)
+ }
+
+ if (prItemsData) {
+ setPrItems(prItemsData)
+ }
+ } catch (error) {
+ console.error('Failed to load bidding company:', error)
+ toast({
+ title: '오류',
+ description: '입찰 정보를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadData()
+ }, [biddingId, companyId, toast])
+
+ // 임시저장 기능
+ const handleTempSave = () => {
+ if (!biddingDetail) return
+
+ setIsSaving(true)
+ startTransition(async () => {
+ 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,
+ isInitialResponse: responseData.isInitialResponse,
+ sparePartResponse: responseData.sparePartResponse,
+ additionalProposals: responseData.additionalProposals,
+ priceAdjustmentForm: responseData.priceAdjustmentResponse ? {
+ 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
+ },
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success) {
+ toast({
+ title: '임시저장 완료',
+ description: result.message,
+ })
+ } else {
+ toast({
+ title: '임시저장 실패',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ setIsSaving(false)
+ })
+ }
+
+ const handleSubmitResponse = () => {
+ if (!biddingDetail) return
+
+ // 필수값 검증
+ if (prItemQuotations.length === 0 || totalAmount === 0) {
+ toast({
+ title: '유효성 오류',
+ description: '품목별 견적을 입력해주세요.',
+ 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,
+ isInitialResponse: responseData.isInitialResponse,
+ sparePartResponse: responseData.sparePartResponse,
+ additionalProposals: responseData.additionalProposals,
+ priceAdjustmentForm: responseData.priceAdjustmentResponse ? {
+ 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,
+ 'current-user' // TODO: 실제 사용자 ID
+ )
+
+ 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,
+ })
+ }
+ } else {
+ toast({
+ title: '오류',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: biddingDetail?.currency || 'KRW',
+ }).format(amount)
+ }
+
+ 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>
+ </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 || "미설정"}</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>
+ )}
+
+ {/* 품목별 견적 작성 섹션 */}
+ {prItems.length > 0 && (
+ <PrItemsPricingTable
+ prItems={prItems}
+ initialQuotations={prItemQuotations}
+ currency={biddingDetail?.currency || 'KRW'}
+ onQuotationsChange={setPrItemQuotations}
+ onTotalAmountChange={setTotalAmount}
+ readOnly={false}
+ />
+ )}
+
+ {/* 견적 문서 업로드 섹션 */}
+ {/* <PreQuoteFileUpload
+ biddingId={biddingId}
+ companyId={companyId}
+ readOnly={biddingDetail?.invitationStatus === 'submitted'}
+ /> */}
+
+ {/* 사전견적 폼 섹션 */}
+ <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">총 사전견적 금액 *</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">제안 납품일</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">응답 지급조건</Label>
+ <Input
+ id="paymentTermsResponse"
+ value={responseData.paymentTermsResponse}
+ onChange={(e) => setResponseData({...responseData, paymentTermsResponse: e.target.value})}
+ placeholder={biddingConditions?.paymentTerms ? `참고: ${biddingConditions.paymentTerms}` : "지급조건에 대한 의견을 입력하세요"}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="taxConditionsResponse">응답 세금조건</Label>
+ <Input
+ id="taxConditionsResponse"
+ value={responseData.taxConditionsResponse}
+ onChange={(e) => setResponseData({...responseData, taxConditionsResponse: e.target.value})}
+ placeholder={biddingConditions?.taxConditions ? `참고: ${biddingConditions.taxConditions}` : "세금조건에 대한 의견을 입력하세요"}
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label htmlFor="incotermsResponse">응답 운송조건</Label>
+ <Input
+ id="incotermsResponse"
+ value={responseData.incotermsResponse}
+ onChange={(e) => setResponseData({...responseData, incotermsResponse: e.target.value})}
+ placeholder={biddingConditions?.incoterms ? `참고: ${biddingConditions.incoterms}` : "운송조건에 대한 의견을 입력하세요"}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="proposedShippingPort">제안 선적지</Label>
+ <Input
+ id="proposedShippingPort"
+ value={responseData.proposedShippingPort}
+ onChange={(e) => setResponseData({...responseData, proposedShippingPort: e.target.value})}
+ placeholder={biddingConditions?.shippingPort ? `참고: ${biddingConditions.shippingPort}` : "선적지를 입력하세요"}
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label htmlFor="proposedDestinationPort">제안 도착지</Label>
+ <Input
+ id="proposedDestinationPort"
+ value={responseData.proposedDestinationPort}
+ onChange={(e) => setResponseData({...responseData, proposedDestinationPort: e.target.value})}
+ placeholder={biddingConditions?.destinationPort ? `참고: ${biddingConditions.destinationPort}` : "도착지를 입력하세요"}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="sparePartResponse">스페어파트 응답</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}
+ >
+ <Save className="w-4 h-4 mr-2" />
+ {isSaving ? '저장중...' : '임시저장'}
+ </Button>
+ <Button onClick={handleSubmitResponse} disabled={isPending || isSaving}>
+ <Send className="w-4 h-4 mr-2" />
+ 사전견적 제출
+ </Button>
+ </>
+
+ {/* {biddingDetail?.invitationStatus === 'submitted' && (
+ <div className="flex items-center gap-2 text-green-600">
+ <CheckCircle className="w-5 h-5" />
+ <span className="font-medium">사전견적이 제출되었습니다</span>
+ </div>
+ )} */}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+}
diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
index c45568bd..324e21d1 100644
--- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
+++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx
@@ -2,19 +2,21 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Users } from "lucide-react"
+import { Users, CheckCircle, XCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { PartnersBiddingListItem } from '../detail/service'
interface PartnersBiddingToolbarActionsProps {
table: Table<PartnersBiddingListItem>
+ companyId: number
onRefresh: () => void
setRowAction?: (action: { type: string; row: { original: PartnersBiddingListItem } }) => void
}
export function PartnersBiddingToolbarActions({
table,
+ companyId,
onRefresh,
setRowAction
}: PartnersBiddingToolbarActionsProps) {
@@ -29,6 +31,11 @@ export function PartnersBiddingToolbarActions({
selectedBidding.invitationStatus === 'submitted'
)
+ // 참여 의사 결정 버튼 활성화 조건 (sent 상태이고 아직 참여의사를 결정하지 않은 경우)
+ const canDecideParticipation = selectedBidding &&
+ selectedBidding.invitationStatus === 'sent' &&
+ selectedBidding.isPreQuoteSelected === null
+
const handleAttendanceClick = () => {
if (selectedBidding && setRowAction) {
setRowAction({
@@ -38,11 +45,31 @@ export function PartnersBiddingToolbarActions({
}
}
+ const handleParticipationClick = () => {
+ if (selectedBidding && setRowAction) {
+ setRowAction({
+ type: 'participation',
+ row: { original: selectedBidding }
+ })
+ }
+ }
+
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
+ onClick={handleParticipationClick}
+ disabled={!canDecideParticipation}
+ className="flex items-center gap-2"
+ >
+ <CheckCircle className="w-4 h-4" />
+ 참여 의사 결정
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
onClick={handleAttendanceClick}
disabled={!canManageAttendance}
className="flex items-center gap-2"
diff --git a/lib/bidding/vendor/vendor-prequote-participation-dialog.tsx b/lib/bidding/vendor/vendor-prequote-participation-dialog.tsx
new file mode 100644
index 00000000..c8098c3d
--- /dev/null
+++ b/lib/bidding/vendor/vendor-prequote-participation-dialog.tsx
@@ -0,0 +1,268 @@
+'use client'
+
+import * as React from 'react'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Badge } from '@/components/ui/badge'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { CheckCircle, XCircle, AlertCircle, Calendar, Package, Building2, User } from 'lucide-react'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+import { formatDate } from '@/lib/utils'
+
+interface VendorPreQuoteParticipationDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ biddingDetail: any // BiddingDetail 타입
+ onParticipationDecision: (participate: boolean) => Promise<void>
+}
+
+export function VendorPreQuoteParticipationDialog({
+ open,
+ onOpenChange,
+ biddingDetail,
+ onParticipationDecision
+}: VendorPreQuoteParticipationDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [selectedDecision, setSelectedDecision] = React.useState<boolean | null>(null)
+
+ const handleSubmit = () => {
+ if (selectedDecision === null) {
+ toast({
+ title: '선택 필요',
+ description: '사전견적 참여 여부를 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ await onParticipationDecision(selectedDecision)
+
+ toast({
+ title: '완료',
+ description: selectedDecision
+ ? '사전견적 참여를 결정했습니다. 이제 견적서를 작성하실 수 있습니다.'
+ : '사전견적 참여를 거절했습니다.',
+ })
+
+ setSelectedDecision(null)
+ onOpenChange(false)
+ } catch (error) {
+ toast({
+ title: '오류',
+ description: '처리 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ onOpenChange(open)
+ if (!open) {
+ setSelectedDecision(null)
+ }
+ }
+
+ if (!biddingDetail) return null
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[700px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <AlertCircle className="w-5 h-5 text-blue-600" />
+ 사전견적 참여 의사 결정
+ </DialogTitle>
+ <DialogDescription>
+ 다음 입찰건에 대한 사전견적 참여 여부를 결정해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="py-4 space-y-6">
+ {/* 입찰 정보 카드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ 입찰 상세 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div>
+ <strong className="text-gray-700">입찰번호:</strong>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="font-mono">{biddingDetail.biddingNumber}</span>
+ {biddingDetail.revision && biddingDetail.revision > 0 && (
+ <Badge variant="outline">Rev.{biddingDetail.revision}</Badge>
+ )}
+ </div>
+ </div>
+
+ <div>
+ <strong className="text-gray-700">프로젝트:</strong>
+ <div className="flex items-center gap-2 mt-1">
+ <Building2 className="w-4 h-4" />
+ <span>{biddingDetail.projectName}</span>
+ </div>
+ </div>
+
+ <div className="md:col-span-2">
+ <strong className="text-gray-700">입찰명:</strong>
+ <div className="mt-1">
+ <span className="text-lg">{biddingDetail.title}</span>
+ </div>
+ </div>
+
+ <div>
+ <strong className="text-gray-700">품목명:</strong>
+ <div className="mt-1">{biddingDetail.itemName}</div>
+ </div>
+
+ <div>
+ <strong className="text-gray-700">담당자:</strong>
+ <div className="flex items-center gap-2 mt-1">
+ <User className="w-4 h-4" />
+ <span>{biddingDetail.managerName}</span>
+ </div>
+ </div>
+
+ {biddingDetail.preQuoteDate && (
+ <div className="md:col-span-2">
+ <strong className="text-gray-700">사전견적 마감일:</strong>
+ <div className="flex items-center gap-2 mt-1">
+ <Calendar className="w-4 h-4 text-red-500" />
+ <span className="text-red-600 font-semibold">
+ {formatDate(biddingDetail.preQuoteDate, 'KR')}
+ </span>
+ </div>
+ </div>
+ )}
+
+ {biddingDetail.budget && (
+ <div>
+ <strong className="text-gray-700">예산:</strong>
+ <div className="mt-1 font-mono">
+ {biddingDetail.budget?.toLocaleString()} {biddingDetail.currency || 'KRW'}
+ </div>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 참여 의사 선택 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold text-gray-900">
+ 사전견적에 참여하시겠습니까?
+ </h3>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 참여 */}
+ <Card
+ className={`cursor-pointer transition-all border-2 ${
+ selectedDecision === true
+ ? 'border-green-500 bg-green-50 shadow-md'
+ : 'border-gray-200 hover:border-green-300 hover:shadow-sm'
+ }`}
+ onClick={() => setSelectedDecision(true)}
+ >
+ <CardContent className="p-6 text-center">
+ <CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
+ <h4 className="text-xl font-semibold text-green-700 mb-2">
+ 참여하겠습니다
+ </h4>
+ <p className="text-sm text-gray-600 leading-relaxed">
+ 사전견적서를 작성하여 제출하겠습니다.<br/>
+ 마감일까지 견적을 완료해주세요.
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 참여 안함 */}
+ <Card
+ className={`cursor-pointer transition-all border-2 ${
+ selectedDecision === false
+ ? 'border-red-500 bg-red-50 shadow-md'
+ : 'border-gray-200 hover:border-red-300 hover:shadow-sm'
+ }`}
+ onClick={() => setSelectedDecision(false)}
+ >
+ <CardContent className="p-6 text-center">
+ <XCircle className="w-16 h-16 text-red-600 mx-auto mb-4" />
+ <h4 className="text-xl font-semibold text-red-700 mb-2">
+ 참여하지 않겠습니다
+ </h4>
+ <p className="text-sm text-gray-600 leading-relaxed">
+ 이번 사전견적에는 참여하지 않겠습니다.<br/>
+ 다음 기회에 참여하겠습니다.
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+
+ {selectedDecision !== null && (
+ <div className={`mt-4 p-4 rounded-lg border ${
+ selectedDecision
+ ? 'bg-green-50 border-green-200'
+ : 'bg-red-50 border-red-200'
+ }`}>
+ <div className="flex items-center gap-2">
+ {selectedDecision ? (
+ <CheckCircle className="w-5 h-5 text-green-600" />
+ ) : (
+ <XCircle className="w-5 h-5 text-red-600" />
+ )}
+ <span className={`font-medium ${
+ selectedDecision ? 'text-green-800' : 'text-red-800'
+ }`}>
+ {selectedDecision
+ ? '사전견적 참여를 선택하셨습니다.'
+ : '사전견적 참여를 거절하셨습니다.'
+ }
+ </span>
+ </div>
+ <p className={`text-sm mt-1 ${
+ selectedDecision ? 'text-green-600' : 'text-red-600'
+ }`}>
+ {selectedDecision
+ ? '확인을 누르시면 견적서 작성 화면으로 이동합니다.'
+ : '확인을 누르시면 이 입찰건의 참여가 종료됩니다.'
+ }
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => handleOpenChange(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isPending || selectedDecision === null}
+ className={selectedDecision === true ? 'bg-green-600 hover:bg-green-700' :
+ selectedDecision === false ? 'bg-red-600 hover:bg-red-700' : ''}
+ >
+ {selectedDecision === true && <CheckCircle className="w-4 h-4 mr-2" />}
+ {selectedDecision === false && <XCircle className="w-4 h-4 mr-2" />}
+ {selectedDecision === true ? '참여 확정' :
+ selectedDecision === false ? '참여 거절' : '선택하세요'}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}