From 058b32e0e5ab5bc6fd02fe57b3dde6e934f91040 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Sun, 14 Sep 2025 06:43:13 +0000 Subject: (최겸) 입찰 긴급여부 추가, 입찰첨부문서 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/detail/service.ts | 234 ++++++++++- .../bidding-detail-vendor-toolbar-actions.tsx | 24 +- .../table/bidding-document-upload-dialog.tsx | 462 +++++++++++++++++++++ lib/bidding/list/biddings-table-columns.tsx | 30 +- lib/bidding/list/create-bidding-dialog.tsx | 23 + lib/bidding/list/edit-bidding-sheet.tsx | 53 ++- lib/bidding/service.ts | 2 + lib/bidding/validation.ts | 2 + .../vendor/partners-bidding-attachments-dialog.tsx | 246 +++++++++++ .../vendor/partners-bidding-list-columns.tsx | 57 ++- lib/bidding/vendor/partners-bidding-list.tsx | 16 + 11 files changed, 1143 insertions(+), 6 deletions(-) create mode 100644 lib/bidding/detail/table/bidding-document-upload-dialog.tsx create mode 100644 lib/bidding/vendor/partners-bidding-attachments-dialog.tsx (limited to 'lib/bidding') diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts index 025b9eac..92be2eee 100644 --- a/lib/bidding/detail/service.ts +++ b/lib/bidding/detail/service.ts @@ -1701,6 +1701,7 @@ export interface PartnersBiddingListItem { managerPhone: string | null currency: string budget: number | null + isUrgent: boolean | null // 긴급여부 // 계산된 필드 responseDeadline: Date | null // 참여회신 마감일 (submissionStartDate 전 3일) @@ -1747,6 +1748,7 @@ export async function getBiddingListForPartners(companyId: number): Promise { + try { + const documents = await db + .select({ + id: biddingDocuments.id, + biddingId: biddingDocuments.biddingId, + companyId: biddingDocuments.companyId, + documentType: biddingDocuments.documentType, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + description: biddingDocuments.description, + uploadedAt: biddingDocuments.uploadedAt, + uploadedBy: biddingDocuments.uploadedBy + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.biddingId, biddingId), + sql`${biddingDocuments.companyId} IS NULL`, // 발주처 문서만 + eq(biddingDocuments.isPublic, true) // 공개 문서만 + ) + ) + .orderBy(desc(biddingDocuments.uploadedAt)) + + return documents + } catch (error) { + console.error('Failed to get bidding documents for partners:', error) + return [] + } + }, + [`bidding-documents-partners-${biddingId}`], + { + tags: [`bidding-${biddingId}`, 'bidding-documents'] + } + )() +} diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx index 654d9941..4e9fc58d 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx @@ -5,9 +5,10 @@ import { type Table } from "@tanstack/react-table" import { useRouter } from "next/navigation" import { useTransition } from "react" import { Button } from "@/components/ui/button" -import { Plus, Send, RotateCcw, XCircle, Trophy } from "lucide-react" +import { Plus, Send, RotateCcw, XCircle, Trophy, FileText } from "lucide-react" import { QuotationVendor, registerBidding, markAsDisposal, createRebidding, awardBidding } from "@/lib/bidding/detail/service" import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog" +import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog" import { Bidding } from "@/db/schema" import { useToast } from "@/hooks/use-toast" @@ -36,11 +37,16 @@ export function BiddingDetailVendorToolbarActions({ const { toast } = useToast() const [isPending, startTransition] = useTransition() const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) + const [isDocumentDialogOpen, setIsDocumentDialogOpen] = React.useState(false) const handleCreateVendor = () => { setIsCreateDialogOpen(true) } + const handleDocumentUpload = () => { + setIsDocumentDialogOpen(true) + } + const handleRegister = () => { startTransition(async () => { const result = await registerBidding(bidding.id, userId) @@ -177,6 +183,14 @@ export function BiddingDetailVendorToolbarActions({ 업체 추가 + + + ) } diff --git a/lib/bidding/detail/table/bidding-document-upload-dialog.tsx b/lib/bidding/detail/table/bidding-document-upload-dialog.tsx new file mode 100644 index 00000000..f1633ea3 --- /dev/null +++ b/lib/bidding/detail/table/bidding-document-upload-dialog.tsx @@ -0,0 +1,462 @@ +'use client' + +import * as React from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +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 { downloadFile } from '@/lib/file-download' +import { + uploadBiddingDocument, + getBiddingDocuments, + deleteBiddingDocument +} from '../service' + +interface UploadedDocument { + id: number + biddingId: number + companyId: number | null + documentType: string + fileName: string + originalFileName: string + fileSize: number | null + filePath: string + title: string | null + description: string | null + uploadedAt: string + uploadedBy: string +} + +interface BiddingDocumentUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + biddingId: number + userId: string + onSuccess?: () => void +} + +const documentTypes = [ + { value: 'notice', label: '입찰공고서' }, + { value: 'specification', label: '사양서' }, + { value: 'specification_meeting', label: '사양설명회' }, + { value: 'contract_draft', label: '계약서 초안' }, + { value: 'financial_doc', label: '재무 관련 문서' }, + { value: 'technical_doc', label: '기술 관련 문서' }, + { value: 'certificate', label: '인증서류' }, + { value: 'pr_document', label: 'PR 문서' }, + { value: 'spec_document', label: 'SPEC 문서' }, + { value: 'evaluation_doc', label: '평가 관련 문서' }, + { value: 'bid_attachment', label: '입찰 첨부파일' }, + { value: 'other', label: '기타' } +] + +export function BiddingDocumentUploadDialog({ + open, + onOpenChange, + biddingId, + userId, + onSuccess +}: BiddingDocumentUploadDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [documents, setDocuments] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + + // 업로드 폼 상태 + const [selectedFile, setSelectedFile] = React.useState(null) + const [documentType, setDocumentType] = React.useState('') + const [title, setTitle] = React.useState('') + const [description, setDescription] = React.useState('') + + // 다이얼로그가 열릴 때 문서 목록 로드 + React.useEffect(() => { + if (open) { + loadDocuments() + resetForm() + } + }, [open, biddingId]) + + const resetForm = () => { + setSelectedFile(null) + setDocumentType('') + setTitle('') + setDescription('') + } + + const loadDocuments = async () => { + setIsLoading(true) + try { + // 서버 액션 직접 호출 + const docs = await getBiddingDocuments(biddingId) + const mappedDocs = docs.map((doc: any) => ({ + ...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) + } + } + + const handleFileSelect = (event: React.ChangeEvent) => { + 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 + } + + setSelectedFile(file) + } + + const handleUpload = async () => { + if (!selectedFile || !documentType) { + toast({ + title: '입력 오류', + description: '파일과 문서 타입을 선택해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + try { + // 서버 액션 직접 호출 + const result = await uploadBiddingDocument( + biddingId, + selectedFile, + documentType, + title, + description, + userId + ) + + if (result.success) { + toast({ + title: '업로드 완료', + description: result.message || '문서가 성공적으로 업로드되었습니다.', + }) + resetForm() + await loadDocuments() + onSuccess?.() + } else { + toast({ + title: '업로드 실패', + description: result.error || '문서 업로드에 실패했습니다.', + variant: 'destructive', + }) + } + } catch (error) { + console.error('Upload error:', error) + toast({ + title: '업로드 실패', + description: '문서 업로드 중 오류가 발생했습니다.', + variant: 'destructive', + }) + } + }) + } + + // 파일 다운로드 + const handleDownload = (document: UploadedDocument) => { + startTransition(async () => { + try { + await downloadFile(document.filePath, document.originalFileName, { + showToast: true + }) + } catch (error) { + toast({ + title: '다운로드 실패', + description: '파일 다운로드에 실패했습니다.', + variant: 'destructive', + }) + } + }) + } + + // 파일 삭제 + const handleDelete = (document: UploadedDocument) => { + if (!confirm(`"${document.originalFileName}" 파일을 삭제하시겠습니까?`)) { + return + } + + startTransition(async () => { + try { + // 서버 액션 직접 호출 + const result = await deleteBiddingDocument(document.id, biddingId, userId) + + if (result.success) { + toast({ + title: '삭제 완료', + description: result.message || '문서가 성공적으로 삭제되었습니다.', + }) + await loadDocuments() + onSuccess?.() + } else { + toast({ + title: '삭제 실패', + description: result.error || '문서 삭제에 실패했습니다.', + variant: 'destructive', + }) + } + } catch (error) { + console.error('Delete error:', 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] + } + + const getDocumentTypeLabel = (type: string) => { + return documentTypes.find(dt => dt.value === type)?.label || type + } + + return ( + + + + + + 입찰 문서 등록 + + + 입찰 관련 문서를 업로드하고 관리할 수 있습니다. + + + +
+ {/* 파일 업로드 섹션 */} + + + 새 문서 업로드 + + +
+
+ + +
+ +
+ + +

+ 지원 형식: PDF, Word, Excel, 이미지, ZIP (최대 50MB) +

+
+
+ +
+ + setTitle(e.target.value)} + /> +
+ +
+ +