summaryrefslogtreecommitdiff
path: root/lib/bidding/pre-quote
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/pre-quote')
-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
4 files changed, 416 insertions, 22 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 || ''}
+ />
</>
)
}