summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-05 03:46:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-05 03:46:21 +0000
commit66d64b482f2b6b52b0dd396ef998f27d491c70dd (patch)
treee616fa2782f26480e8a3c67663f78b8d681a7510
parentc05596247bf396260375f3e193300650b731ee61 (diff)
(최겸) 구매 입찰 내 견적 첨부파일, PR 견적가 조회 기능 추가
-rw-r--r--lib/bidding/pre-quote/service.ts163
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx224
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx30
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx21
-rw-r--r--lib/bidding/vendor/components/pre-quote-file-upload.tsx367
-rw-r--r--lib/bidding/vendor/components/simple-file-upload.tsx315
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx2
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx38
-rw-r--r--lib/bidding/vendor/partners-bidding-pre-quote.tsx12
-rw-r--r--lib/bidding/vendor/partners-bidding-toolbar-actions.tsx4
-rw-r--r--lib/bidding/vendor/vendor-prequote-participation-dialog.tsx268
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>
- )
-}