diff options
Diffstat (limited to 'lib')
11 files changed, 747 insertions, 697 deletions
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts index c34f6f9e..7f0a9083 100644 --- a/lib/bidding/pre-quote/service.ts +++ b/lib/bidding/pre-quote/service.ts @@ -5,6 +5,8 @@ import { biddingCompanies, companyConditionResponses, biddings, prItemsForBiddin import { vendors } from '@/db/schema/vendors' import { sendEmail } from '@/lib/mail/sendEmail' import { eq, inArray, and } from 'drizzle-orm' +import { saveFile } from '@/lib/file-stroage' +import { downloadFile } from '@/lib/file-download' interface CreateBiddingCompanyInput { biddingId: number @@ -524,17 +526,17 @@ export async function submitPreQuoteResponse( majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, - referenceDate: responseData.priceAdjustmentForm.referenceDate ? new Date(responseData.priceAdjustmentForm.referenceDate) : null, - comparisonDate: responseData.priceAdjustmentForm.comparisonDate ? new Date(responseData.priceAdjustmentForm.comparisonDate) : null, - adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio, + referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null, + comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null, + adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null, notes: responseData.priceAdjustmentForm.notes, adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, contractorWriter: responseData.priceAdjustmentForm.contractorWriter, - adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate ? new Date(responseData.priceAdjustmentForm.adjustmentDate) : null, + adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null, nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, - } + } as any // 기존 연동제 정보가 있는지 확인 const existingPriceAdjustment = await tx @@ -785,17 +787,17 @@ export async function savePreQuoteDraft( majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial, adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula, rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex, - referenceDate: responseData.priceAdjustmentForm.referenceDate ? new Date(responseData.priceAdjustmentForm.referenceDate) : null, - comparisonDate: responseData.priceAdjustmentForm.comparisonDate ? new Date(responseData.priceAdjustmentForm.comparisonDate) : null, - adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio, + referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null, + comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null, + adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null, notes: responseData.priceAdjustmentForm.notes, adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions, majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial, adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod, contractorWriter: responseData.priceAdjustmentForm.contractorWriter, - adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate ? new Date(responseData.priceAdjustmentForm.adjustmentDate) : null, + adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null, nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason, - } + } as any // 기존 연동제 정보가 있는지 확인 const existingPriceAdjustment = await tx @@ -835,21 +837,37 @@ export async function savePreQuoteDraft( export async function uploadPreQuoteDocument( biddingId: number, companyId: number, - documentInfo: PreQuoteDocumentUpload, + file: File, userId: string ) { try { + // 파일 저장 + const saveResult = await saveFile({ + file, + directory: `bidding/${biddingId}/quotations`, + originalName: file.name, + userId + }) + + if (!saveResult.success) { + return { + success: false, + error: saveResult.error || '파일 저장에 실패했습니다.' + } + } + + // 데이터베이스에 문서 정보 저장 const result = await db.insert(biddingDocuments) .values({ biddingId, companyId, documentType: 'other', // 견적서 타입 - fileName: documentInfo.fileName, - originalFileName: documentInfo.originalFileName, - fileSize: documentInfo.fileSize, - mimeType: documentInfo.mimeType, - filePath: documentInfo.filePath, - title: `견적서 - ${documentInfo.originalFileName}`, + fileName: saveResult.fileName!, + originalFileName: file.name, + fileSize: file.size, + mimeType: file.type, + filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로) + title: `견적서 - ${file.name}`, description: '협력업체 제출 견적서', isPublic: false, isRequired: false, @@ -884,7 +902,8 @@ export async function getPreQuoteDocuments(biddingId: number, companyId: number) filePath: biddingDocuments.filePath, title: biddingDocuments.title, description: biddingDocuments.description, - uploadedAt: biddingDocuments.uploadedAt + uploadedAt: biddingDocuments.uploadedAt, + uploadedBy: biddingDocuments.uploadedBy }) .from(biddingDocuments) .where( @@ -934,4 +953,112 @@ export async function getSavedPrItemQuotations(biddingCompanyId: number) { console.error('Failed to get saved PR item quotations:', error) return [] } + } + +// 견적 문서 정보 조회 (다운로드용) +export async function getPreQuoteDocumentForDownload( + documentId: number, + biddingId: number, + companyId: number +) { + try { + const document = await db + .select({ + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + filePath: biddingDocuments.filePath + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.id, documentId), + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.companyId, companyId), + eq(biddingDocuments.documentType, 'other') + ) + ) + .limit(1) + + if (document.length === 0) { + return { + success: false, + error: '문서를 찾을 수 없습니다.' + } + } + + return { + success: true, + document: document[0] + } + } catch (error) { + console.error('Failed to get pre-quote document:', error) + return { + success: false, + error: '문서 정보 조회에 실패했습니다.' + } + } +} + +// 견적 문서 삭제 +export async function deletePreQuoteDocument( + documentId: number, + biddingId: number, + companyId: number, + userId: string +) { + try { + // 문서 존재 여부 및 권한 확인 + const document = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + filePath: biddingDocuments.filePath, + uploadedBy: biddingDocuments.uploadedBy + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.id, documentId), + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.companyId, companyId), + eq(biddingDocuments.documentType, 'other') + ) + ) + .limit(1) + + if (document.length === 0) { + return { + success: false, + error: '문서를 찾을 수 없습니다.' + } + } + + const doc = document[0] + + // 권한 확인 (업로드한 사용자만 삭제 가능) + if (doc.uploadedBy !== userId) { + return { + success: false, + error: '삭제 권한이 없습니다.' + } + } + + // 데이터베이스에서 문서 정보 삭제 + await db + .delete(biddingDocuments) + .where(eq(biddingDocuments.id, documentId)) + + // TODO: 실제 파일도 삭제하는 로직 추가 (필요시) + + return { + success: true, + message: '문서가 성공적으로 삭제되었습니다.' + } + } catch (error) { + console.error('Failed to delete pre-quote document:', error) + return { + success: false, + error: '문서 삭제에 실패했습니다.' + } + } }
\ No newline at end of file diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx new file mode 100644 index 00000000..cfa629e3 --- /dev/null +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx @@ -0,0 +1,224 @@ +'use client' + +import * as React from 'react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + FileText, + Download, + User, + Calendar +} from 'lucide-react' +import { useToast } from '@/hooks/use-toast' +import { useTransition } from 'react' +import { getPreQuoteDocuments, getPreQuoteDocumentForDownload } from '../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 BiddingPreQuoteAttachmentsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + biddingId: number + companyId: number + companyName: string +} + +export function BiddingPreQuoteAttachmentsDialog({ + open, + onOpenChange, + biddingId, + companyId, + companyName +}: BiddingPreQuoteAttachmentsDialogProps) { + const { toast } = useToast() + const [isPending, startTransition] = useTransition() + const [documents, setDocuments] = React.useState<UploadedDocument[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + // 다이얼로그가 열릴 때 첨부파일 목록 로드 + React.useEffect(() => { + if (open) { + loadDocuments() + } + }, [open, biddingId, companyId]) + + const loadDocuments = async () => { + setIsLoading(true) + try { + 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) + } + } + + // 파일 다운로드 + 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 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileText className="w-5 h-5" /> + <span>협력업체 첨부파일</span> + <span className="text-sm font-normal text-muted-foreground"> + - {companyName} + </span> + </DialogTitle> + <DialogDescription> + 협력업체가 제출한 견적 관련 첨부파일 목록입니다. + </DialogDescription> + </DialogHeader> + + {isLoading ? ( + <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> + ) : documents.length > 0 ? ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <Badge variant="secondary" className="text-sm"> + 총 {documents.length}개 파일 + </Badge> + </div> + + <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"> + <div className="flex items-center gap-1"> + <Calendar className="w-3 h-3" /> + {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')} + </div> + </TableCell> + <TableCell className="text-sm text-gray-500"> + <div className="flex items-center gap-1"> + <User className="w-3 h-3" /> + {doc.uploadedBy} + </div> + </TableCell> + <TableCell> + <Button + variant="outline" + size="sm" + onClick={() => handleDownload(doc)} + disabled={isPending} + title="다운로드" + > + <Download className="w-3 h-3" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + ) : ( + <div className="text-center py-12 text-gray-500"> + <FileText className="w-12 h-12 mx-auto mb-4 opacity-50" /> + <p className="text-lg font-medium mb-2">첨부파일이 없습니다</p> + <p className="text-sm">협력업체가 아직 첨부파일을 업로드하지 않았습니다.</p> + </div> + )} + </DialogContent> + </Dialog> + ) +} diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx index 39fcb30f..f28f9e1f 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx @@ -6,7 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { - MoreHorizontal, Edit, Trash2, UserPlus + MoreHorizontal, Edit, Trash2, UserPlus, Paperclip } from "lucide-react" import { DropdownMenu, @@ -59,6 +59,7 @@ interface GetBiddingCompanyColumnsProps { onInvite: (company: BiddingCompany) => void onViewPriceAdjustment?: (company: BiddingCompany) => void onViewItemDetails?: (company: BiddingCompany) => void + onViewAttachments?: (company: BiddingCompany) => void } export function getBiddingPreQuoteVendorColumns({ @@ -66,7 +67,8 @@ export function getBiddingPreQuoteVendorColumns({ onDelete, onInvite, onViewPriceAdjustment, - onViewItemDetails + onViewItemDetails, + onViewAttachments }: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] { return [ { @@ -148,6 +150,30 @@ export function getBiddingPreQuoteVendorColumns({ ), }, { + accessorKey: 'attachments', + header: '첨부파일', + cell: ({ row }) => { + const hasAttachments = row.original.preQuoteSubmittedAt // 제출된 경우에만 첨부파일이 있을 수 있음 + return ( + <div className="text-center"> + {hasAttachments ? ( + <Button + variant="ghost" + size="sm" + onClick={() => onViewAttachments?.(row.original)} + className="h-8 w-8 p-0" + title="첨부파일 보기" + > + <Paperclip className="h-4 w-4" /> + </Button> + ) : ( + <span className="text-muted-foreground text-sm">-</span> + )} + </div> + ) + }, + }, + { accessorKey: 'isPreQuoteSelected', header: '본입찰 선정', cell: ({ row }) => ( diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx index 346bf9a6..a1319821 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx @@ -17,6 +17,7 @@ import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog' import { BiddingPreQuoteItemDetailsDialog } from './bidding-pre-quote-item-details-dialog' +import { BiddingPreQuoteAttachmentsDialog } from './bidding-pre-quote-attachments-dialog' import { getPrItemsForBidding } from '../service' interface BiddingPreQuoteVendorTableContentProps { @@ -106,6 +107,8 @@ export function BiddingPreQuoteVendorTableContent({ const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false) const [selectedCompanyForDetails, setSelectedCompanyForDetails] = React.useState<BiddingCompany | null>(null) const [prItems, setPrItems] = React.useState<any[]>([]) + const [isAttachmentsDialogOpen, setIsAttachmentsDialogOpen] = React.useState(false) + const [selectedCompanyForAttachments, setSelectedCompanyForAttachments] = React.useState<BiddingCompany | null>(null) const handleDelete = (company: BiddingCompany) => { if (!confirm(`${company.companyName} 업체를 삭제하시겠습니까?`)) return @@ -178,15 +181,21 @@ export function BiddingPreQuoteVendorTableContent({ }) } + const handleViewAttachments = (company: BiddingCompany) => { + setSelectedCompanyForAttachments(company) + setIsAttachmentsDialogOpen(true) + } + const columns = React.useMemo( () => getBiddingPreQuoteVendorColumns({ onEdit: onEdit || handleEdit, onDelete: onDelete || handleDelete, onInvite: handleInvite, onViewPriceAdjustment: handleViewPriceAdjustment, - onViewItemDetails: handleViewItemDetails + onViewItemDetails: handleViewItemDetails, + onViewAttachments: handleViewAttachments }), - [onEdit, onDelete, handleEdit, handleDelete, handleInvite, handleViewPriceAdjustment, handleViewItemDetails] + [onEdit, onDelete, handleEdit, handleDelete, handleInvite, handleViewPriceAdjustment, handleViewItemDetails, handleViewAttachments] ) const { table } = useDataTable({ @@ -248,6 +257,14 @@ export function BiddingPreQuoteVendorTableContent({ prItems={prItems} currency={bidding.currency || 'KRW'} /> + + <BiddingPreQuoteAttachmentsDialog + open={isAttachmentsDialogOpen} + onOpenChange={setIsAttachmentsDialogOpen} + biddingId={biddingId} + companyId={selectedCompanyForAttachments?.companyId || 0} + companyName={selectedCompanyForAttachments?.companyName || ''} + /> </> ) } 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> + ) +} diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx index 04575550..0d1e3123 100644 --- a/lib/bidding/vendor/partners-bidding-list-columns.tsx +++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx @@ -127,7 +127,7 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL row.original.invitationStatus === 'accepted' // 사전견적이 가능한 조건: 초대 발송(sent) 상태인 경우 - const canDoPreQuote = row.original.invitationStatus === 'sent' + const canDoPreQuote = row.original.invitationStatus === 'sent' || row.original.invitationStatus === 'pending' || row.original.invitationStatus === 'submitted'; return ( <DropdownMenu> diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx index a13334ef..9f182911 100644 --- a/lib/bidding/vendor/partners-bidding-list.tsx +++ b/lib/bidding/vendor/partners-bidding-list.tsx @@ -16,7 +16,6 @@ import { getBiddingListForPartners, PartnersBiddingListItem } from '../detail/se 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 { @@ -30,7 +29,6 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { 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() @@ -48,20 +46,7 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { } }, [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('업체 정보를 찾을 수 없습니다.') @@ -112,15 +97,15 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { router.push(`/partners/bid/${rowAction.row.original.biddingId}/pre-quote`) break case 'participation': - // 사전견적 참여 의사 결정 다이얼로그 열기 - 상세 데이터 로드 필요 - loadBiddingDetailForParticipation(rowAction.row.original) + // 입찰 참여 의사 결정 다이얼로그 열기 - 상세 데이터 로드 필요 + handlePreQuoteParticipationDecision(true) setRowAction(null) // rowAction 초기화 break default: break } } - }, [rowAction, router, loadBiddingDetailForParticipation]) + }, [rowAction, router, handlePreQuoteParticipationDecision]) const columns = React.useMemo( () => getPartnersBiddingListColumns({ setRowAction }), @@ -234,7 +219,7 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { isAttending={rowAction?.row.original?.isAttendingMeeting || null} onSuccess={refreshData} /> - +{/* <PartnersBiddingParticipationDialog open={isParticipationDialogOpen} onOpenChange={setIsParticipationDialogOpen} @@ -244,19 +229,8 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) { 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-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx index 7b29b1a6..94b76f58 100644 --- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx +++ b/lib/bidding/vendor/partners-bidding-pre-quote.tsx @@ -35,7 +35,7 @@ import { import { getBiddingConditions } from '../service' import { getPriceAdjustmentFormByBiddingCompanyId } from '../detail/service' import { PrItemsPricingTable } from './components/pr-items-pricing-table' -import { PreQuoteFileUpload } from './components/pre-quote-file-upload' +import { SimpleFileUpload } from './components/simple-file-upload' import { biddingStatusLabels, contractTypeLabels, @@ -43,6 +43,7 @@ import { } from '@/db/schema' import { useToast } from '@/hooks/use-toast' import { useTransition } from 'react' +import { useSession } from 'next-auth/react' interface PartnersBiddingPreQuoteProps { biddingId: number @@ -98,6 +99,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin const router = useRouter() const { toast } = useToast() const [isPending, startTransition] = useTransition() + const session = useSession() const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null) const [isLoading, setIsLoading] = React.useState(true) const [biddingConditions, setBiddingConditions] = React.useState<any | null>(null) @@ -142,6 +144,7 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin adjustmentDate: '', nonApplicableReason: '', }) + const userId = session.data?.user?.id || '' // 데이터 로드 React.useEffect(() => { @@ -627,11 +630,12 @@ export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddin )} {/* 견적 문서 업로드 섹션 */} - {/* <PreQuoteFileUpload + <SimpleFileUpload biddingId={biddingId} companyId={companyId} - readOnly={biddingDetail?.invitationStatus === 'submitted'} - /> */} + userId={userId} + readOnly={false} + /> {/* 사전견적 폼 섹션 */} <Card> diff --git a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx index 324e21d1..c2fb6487 100644 --- a/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx +++ b/lib/bidding/vendor/partners-bidding-toolbar-actions.tsx @@ -32,9 +32,7 @@ export function PartnersBiddingToolbarActions({ ) // 참여 의사 결정 버튼 활성화 조건 (sent 상태이고 아직 참여의사를 결정하지 않은 경우) - const canDecideParticipation = selectedBidding && - selectedBidding.invitationStatus === 'sent' && - selectedBidding.isPreQuoteSelected === null + const canDecideParticipation = selectedBidding const handleAttendanceClick = () => { if (selectedBidding && setRowAction) { diff --git a/lib/bidding/vendor/vendor-prequote-participation-dialog.tsx b/lib/bidding/vendor/vendor-prequote-participation-dialog.tsx deleted file mode 100644 index c8098c3d..00000000 --- a/lib/bidding/vendor/vendor-prequote-participation-dialog.tsx +++ /dev/null @@ -1,268 +0,0 @@ -'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> - ) -} |
