summaryrefslogtreecommitdiff
path: root/lib/bidding/vendor/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-04 08:31:31 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-04 08:31:31 +0000
commitb67e36df49f067cbd5ba899f9fbcc755f38d4b4f (patch)
tree5a71c5960f90d988cd509e3ef26bff497a277661 /lib/bidding/vendor/components
parentb7f54b06c1ef9e619f5358fb0a5caad9703c8905 (diff)
(대표님, 최겸, 임수민) 작업사항 커밋
Diffstat (limited to 'lib/bidding/vendor/components')
-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
3 files changed, 1098 insertions, 0 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>
+ )
+}