diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-05 03:46:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-05 03:46:21 +0000 |
| commit | 66d64b482f2b6b52b0dd396ef998f27d491c70dd (patch) | |
| tree | e616fa2782f26480e8a3c67663f78b8d681a7510 /lib/bidding/vendor/components | |
| parent | c05596247bf396260375f3e193300650b731ee61 (diff) | |
(최겸) 구매 입찰 내 견적 첨부파일, PR 견적가 조회 기능 추가
Diffstat (limited to 'lib/bidding/vendor/components')
| -rw-r--r-- | lib/bidding/vendor/components/pre-quote-file-upload.tsx | 367 | ||||
| -rw-r--r-- | lib/bidding/vendor/components/simple-file-upload.tsx | 315 |
2 files changed, 315 insertions, 367 deletions
diff --git a/lib/bidding/vendor/components/pre-quote-file-upload.tsx b/lib/bidding/vendor/components/pre-quote-file-upload.tsx deleted file mode 100644 index b6d8990b..00000000 --- a/lib/bidding/vendor/components/pre-quote-file-upload.tsx +++ /dev/null @@ -1,367 +0,0 @@ -'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/components/simple-file-upload.tsx b/lib/bidding/vendor/components/simple-file-upload.tsx new file mode 100644 index 00000000..b1eb8b8f --- /dev/null +++ b/lib/bidding/vendor/components/simple-file-upload.tsx @@ -0,0 +1,315 @@ +'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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Upload, + FileText, + Download, + Trash2 +} from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { + uploadPreQuoteDocument, + getPreQuoteDocuments, + getPreQuoteDocumentForDownload, + deletePreQuoteDocument +} from '../../pre-quote/service' +import { downloadFile } from '@/lib/file-download' + +interface UploadedDocument { + id: number + fileName: string + originalFileName: string + fileSize: number | null + filePath: string + title: string | null + description: string | null + uploadedAt: string + uploadedBy: string +} + +interface SimpleFileUploadProps { + biddingId: number + companyId: number + userId: string + readOnly?: boolean +} + +export function SimpleFileUpload({ + biddingId, + companyId, + userId, + readOnly = false +}: SimpleFileUploadProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [documents, setDocuments] = React.useState<UploadedDocument[]>([]) + const [isLoading, setIsLoading] = React.useState(true) + + // 업로드된 문서 목록 로드 + const loadDocuments = React.useCallback(async () => { + try { + setIsLoading(true) + const docs = await getPreQuoteDocuments(biddingId, companyId) + // Date를 string으로 변환 + const mappedDocs = docs.map(doc => ({ + ...doc, + uploadedAt: doc.uploadedAt.toString(), + uploadedBy: doc.uploadedBy || '' + })) + setDocuments(mappedDocs) + } catch (error) { + console.error('Failed to load documents:', error) + toast({ + title: '오류', + description: '업로드된 문서 목록을 불러오는데 실패했습니다.', + variant: 'destructive', + }) + } finally { + setIsLoading(false) + } + }, [biddingId, companyId, toast]) + + React.useEffect(() => { + loadDocuments() + }, [loadDocuments]) + + // 파일 업로드 처리 + const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { + const files = event.target.files + if (!files || files.length === 0) return + + const file = files[0] + + // 파일 크기 체크 (50MB 제한) + if (file.size > 50 * 1024 * 1024) { + toast({ + title: '파일 크기 초과', + description: '파일 크기가 50MB를 초과합니다.', + variant: 'destructive', + }) + return + } + + // 파일 타입 체크 + 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: 'PDF, Word, Excel, 이미지, ZIP 파일만 업로드 가능합니다.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + const result = await uploadPreQuoteDocument(biddingId, companyId, file, userId) + + if (result.success) { + toast({ + title: '업로드 완료', + description: result.message, + }) + await loadDocuments() // 문서 목록 새로고침 + } else { + toast({ + title: '업로드 실패', + description: result.error, + variant: 'destructive', + }) + } + }) + + // input 초기화 + event.target.value = '' + } + + // 파일 다운로드 + const handleDownload = (document: UploadedDocument) => { + startTransition(async () => { + const result = await getPreQuoteDocumentForDownload(document.id, biddingId, companyId) + + if (result.success) { + try { + await downloadFile(result.document?.filePath, result.document?.originalFileName, { + showToast: true + }) + } catch (error) { + toast({ + title: '다운로드 실패', + description: '파일 다운로드에 실패했습니다.', + variant: 'destructive', + }) + } + } else { + toast({ + title: '다운로드 실패', + description: result.error, + variant: 'destructive', + }) + } + }) + } + + // 파일 삭제 + const handleDelete = (document: UploadedDocument) => { + if (!confirm(`"${document.originalFileName}" 파일을 삭제하시겠습니까?`)) { + return + } + + startTransition(async () => { + const result = await deletePreQuoteDocument(document.id, biddingId, companyId, userId) + + if (result.success) { + toast({ + title: '삭제 완료', + description: result.message, + }) + await loadDocuments() // 문서 목록 새로고침 + } else { + toast({ + title: '삭제 실패', + description: result.error, + 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="space-y-2"> + <Label htmlFor="file-upload">견적서 파일</Label> + <Input + id="file-upload" + type="file" + accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.zip" + onChange={handleFileUpload} + disabled={isPending} + /> + <p className="text-xs text-muted-foreground"> + 지원 형식: PDF, Word, Excel, 이미지, ZIP (최대 50MB) + </p> + </div> + )} + + {/* 업로드된 문서 목록 */} + {isLoading ? ( + <div className="text-center py-4"> + <p className="text-muted-foreground">문서 목록을 불러오는 중...</p> + </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>작성자</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 className="text-sm text-gray-500"> + {doc.uploadedBy} + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Button + variant="outline" + size="sm" + onClick={() => handleDownload(doc)} + disabled={isPending} + title="다운로드" + > + <Download className="w-3 h-3" /> + </Button> + {!readOnly && doc.uploadedBy === userId && ( + <Button + variant="outline" + size="sm" + onClick={() => handleDelete(doc)} + disabled={isPending} + title="삭제" + className="text-red-600 hover:text-red-700" + > + <Trash2 className="w-3 h-3" /> + </Button> + )} + </div> + </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> + )} + </CardContent> + </Card> + ) +} |
