diff options
Diffstat (limited to 'lib/bidding/pre-quote')
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 || ''} + /> </> ) } |
