diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-04 08:31:31 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-04 08:31:31 +0000 |
| commit | b67e36df49f067cbd5ba899f9fbcc755f38d4b4f (patch) | |
| tree | 5a71c5960f90d988cd509e3ef26bff497a277661 /lib/bidding/vendor/components | |
| parent | b7f54b06c1ef9e619f5358fb0a5caad9703c8905 (diff) | |
(대표님, 최겸, 임수민) 작업사항 커밋
Diffstat (limited to 'lib/bidding/vendor/components')
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> + ) +} |
