summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 06:43:13 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-14 06:43:13 +0000
commit058b32e0e5ab5bc6fd02fe57b3dde6e934f91040 (patch)
treeffe4a25bc3d0f31a41eef399ed633c12a51e420a /lib
parent675b4e3d8ffcb57a041db285417d81e61284d900 (diff)
(최겸) 입찰 긴급여부 추가, 입찰첨부문서 추가
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/detail/service.ts234
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx24
-rw-r--r--lib/bidding/detail/table/bidding-document-upload-dialog.tsx462
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx30
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx23
-rw-r--r--lib/bidding/list/edit-bidding-sheet.tsx53
-rw-r--r--lib/bidding/service.ts2
-rw-r--r--lib/bidding/validation.ts2
-rw-r--r--lib/bidding/vendor/partners-bidding-attachments-dialog.tsx246
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx57
-rw-r--r--lib/bidding/vendor/partners-bidding-list.tsx16
11 files changed, 1143 insertions, 6 deletions
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 025b9eac..92be2eee 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -1701,6 +1701,7 @@ export interface PartnersBiddingListItem {
managerPhone: string | null
currency: string
budget: number | null
+ isUrgent: boolean | null // 긴급여부
// 계산된 필드
responseDeadline: Date | null // 참여회신 마감일 (submissionStartDate 전 3일)
@@ -1747,6 +1748,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
managerPhone: biddings.managerPhone,
currency: biddings.currency,
budget: biddings.budget,
+ isUrgent: biddings.isUrgent,
hasSpecificationMeeting: biddings.hasSpecificationMeeting,
})
.from(biddingCompanies)
@@ -1814,6 +1816,7 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
// 상태 및 담당자
status: biddings.status,
+ isUrgent: biddings.isUrgent,
managerName: biddings.managerName,
managerEmail: biddings.managerEmail,
managerPhone: biddings.managerPhone,
@@ -2278,7 +2281,7 @@ export async function getPriceAdjustmentForm(companyConditionResponseId: number)
}
}
-// 입찰업체 ID로 연동제 정보 조회
+// 입찰업체 ID로 연동제 정보 조회
export async function getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId: number) {
try {
const result = await db
@@ -2297,3 +2300,232 @@ export async function getPriceAdjustmentFormByBiddingCompanyId(biddingCompanyId:
return null
}
}
+
+// =================================================
+// 입찰 문서 관리 함수들 (발주처 문서용)
+// =================================================
+
+// 입찰 문서 업로드 (발주처 문서용 - companyId: null)
+export async function uploadBiddingDocument(
+ biddingId: number,
+ file: File,
+ documentType: string,
+ title: string,
+ description: string,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ // 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: `bidding/${biddingId}/documents`,
+ originalName: file.name,
+ userId
+ })
+
+ if (!saveResult.success) {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+
+ // 데이터베이스에 문서 정보 저장 (companyId는 null로 설정)
+ const result = await db.insert(biddingDocuments)
+ .values({
+ biddingId,
+ companyId: null, // 발주처 문서
+ documentType: documentType as any,
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로)
+ title,
+ description,
+ isPublic: true, // 발주처 문서는 기본적으로 공개
+ isRequired: false,
+ uploadedBy: userName,
+ uploadedAt: new Date()
+ })
+ .returning()
+
+ // 캐시 무효화
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-documents')
+
+ return {
+ success: true,
+ message: '문서가 성공적으로 업로드되었습니다.',
+ documentId: result[0].id
+ }
+ } catch (error) {
+ console.error('Failed to upload bidding document:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '문서 업로드에 실패했습니다.'
+ }
+ }
+}
+
+// 업로드된 입찰 문서 목록 조회 (발주처 문서용)
+export async function getBiddingDocuments(biddingId: number) {
+ try {
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ biddingId: biddingDocuments.biddingId,
+ companyId: biddingDocuments.companyId,
+ documentType: biddingDocuments.documentType,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ sql`${biddingDocuments.companyId} IS NULL` // 발주처 문서만
+ )
+ )
+ .orderBy(desc(biddingDocuments.uploadedAt))
+
+ return documents
+ } catch (error) {
+ console.error('Failed to get bidding documents:', error)
+ return []
+ }
+}
+
+// 입찰 문서 다운로드용 정보 조회
+export async function getBiddingDocumentForDownload(documentId: number, biddingId: number) {
+ try {
+ const documents = await db
+ .select()
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ sql`${biddingDocuments.companyId} IS NULL` // 발주처 문서만
+ )
+ )
+ .limit(1)
+
+ if (documents.length === 0) {
+ return {
+ success: false,
+ error: '문서를 찾을 수 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ document: documents[0]
+ }
+ } catch (error) {
+ console.error('Failed to get bidding document for download:', error)
+ return {
+ success: false,
+ error: '문서 다운로드 준비에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 문서 삭제 (발주처 문서용)
+export async function deleteBiddingDocument(documentId: number, biddingId: number, userId: string) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ // 문서 정보 조회 (업로더 확인)
+ const documents = await db
+ .select()
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ sql`${biddingDocuments.companyId} IS NULL`, // 발주처 문서만
+ eq(biddingDocuments.uploadedBy, userName)
+ )
+ )
+ .limit(1)
+
+ if (documents.length === 0) {
+ return {
+ success: false,
+ error: '삭제할 수 있는 문서가 없습니다.'
+ }
+ }
+
+ // DB에서 삭제
+ await db
+ .delete(biddingDocuments)
+ .where(eq(biddingDocuments.id, documentId))
+
+ // 캐시 무효화
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-documents')
+
+ return {
+ success: true,
+ message: '문서가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete bidding document:', error)
+ return {
+ success: false,
+ error: '문서 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// 협력업체용 발주처 문서 조회 (캐시 적용)
+export async function getBiddingDocumentsForPartners(biddingId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ biddingId: biddingDocuments.biddingId,
+ companyId: biddingDocuments.companyId,
+ documentType: biddingDocuments.documentType,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ sql`${biddingDocuments.companyId} IS NULL`, // 발주처 문서만
+ eq(biddingDocuments.isPublic, true) // 공개 문서만
+ )
+ )
+ .orderBy(desc(biddingDocuments.uploadedAt))
+
+ return documents
+ } catch (error) {
+ console.error('Failed to get bidding documents for partners:', error)
+ return []
+ }
+ },
+ [`bidding-documents-partners-${biddingId}`],
+ {
+ tags: [`bidding-${biddingId}`, 'bidding-documents']
+ }
+ )()
+}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index 654d9941..4e9fc58d 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -5,9 +5,10 @@ import { type Table } from "@tanstack/react-table"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { Button } from "@/components/ui/button"
-import { Plus, Send, RotateCcw, XCircle, Trophy } from "lucide-react"
+import { Plus, Send, RotateCcw, XCircle, Trophy, FileText } from "lucide-react"
import { QuotationVendor, registerBidding, markAsDisposal, createRebidding, awardBidding } from "@/lib/bidding/detail/service"
import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog"
+import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog"
import { Bidding } from "@/db/schema"
import { useToast } from "@/hooks/use-toast"
@@ -36,11 +37,16 @@ export function BiddingDetailVendorToolbarActions({
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
+ const [isDocumentDialogOpen, setIsDocumentDialogOpen] = React.useState(false)
const handleCreateVendor = () => {
setIsCreateDialogOpen(true)
}
+ const handleDocumentUpload = () => {
+ setIsDocumentDialogOpen(true)
+ }
+
const handleRegister = () => {
startTransition(async () => {
const result = await registerBidding(bidding.id, userId)
@@ -177,6 +183,14 @@ export function BiddingDetailVendorToolbarActions({
<Plus className="mr-2 h-4 w-4" />
업체 추가
</Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDocumentUpload}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ 입찰문서 등록
+ </Button>
</div>
<BiddingDetailVendorCreateDialog
@@ -188,6 +202,14 @@ export function BiddingDetailVendorToolbarActions({
setIsCreateDialogOpen(false)
}}
/>
+
+ <BiddingDocumentUploadDialog
+ open={isDocumentDialogOpen}
+ onOpenChange={setIsDocumentDialogOpen}
+ biddingId={biddingId}
+ userId={userId}
+ onSuccess={onSuccess}
+ />
</>
)
}
diff --git a/lib/bidding/detail/table/bidding-document-upload-dialog.tsx b/lib/bidding/detail/table/bidding-document-upload-dialog.tsx
new file mode 100644
index 00000000..f1633ea3
--- /dev/null
+++ b/lib/bidding/detail/table/bidding-document-upload-dialog.tsx
@@ -0,0 +1,462 @@
+'use client'
+
+import * as React from 'react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Upload,
+ FileText,
+ Download,
+ Trash2
+} from 'lucide-react'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+import { downloadFile } from '@/lib/file-download'
+import {
+ uploadBiddingDocument,
+ getBiddingDocuments,
+ deleteBiddingDocument
+} from '../service'
+
+interface UploadedDocument {
+ id: number
+ biddingId: number
+ companyId: number | null
+ documentType: string
+ fileName: string
+ originalFileName: string
+ fileSize: number | null
+ filePath: string
+ title: string | null
+ description: string | null
+ uploadedAt: string
+ uploadedBy: string
+}
+
+interface BiddingDocumentUploadDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ biddingId: number
+ userId: string
+ onSuccess?: () => void
+}
+
+const documentTypes = [
+ { value: 'notice', label: '입찰공고서' },
+ { value: 'specification', label: '사양서' },
+ { value: 'specification_meeting', label: '사양설명회' },
+ { value: 'contract_draft', label: '계약서 초안' },
+ { value: 'financial_doc', label: '재무 관련 문서' },
+ { value: 'technical_doc', label: '기술 관련 문서' },
+ { value: 'certificate', label: '인증서류' },
+ { value: 'pr_document', label: 'PR 문서' },
+ { value: 'spec_document', label: 'SPEC 문서' },
+ { value: 'evaluation_doc', label: '평가 관련 문서' },
+ { value: 'bid_attachment', label: '입찰 첨부파일' },
+ { value: 'other', label: '기타' }
+]
+
+export function BiddingDocumentUploadDialog({
+ open,
+ onOpenChange,
+ biddingId,
+ userId,
+ onSuccess
+}: BiddingDocumentUploadDialogProps) {
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [documents, setDocuments] = React.useState<UploadedDocument[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 업로드 폼 상태
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
+ const [documentType, setDocumentType] = React.useState<string>('')
+ const [title, setTitle] = React.useState('')
+ const [description, setDescription] = React.useState('')
+
+ // 다이얼로그가 열릴 때 문서 목록 로드
+ React.useEffect(() => {
+ if (open) {
+ loadDocuments()
+ resetForm()
+ }
+ }, [open, biddingId])
+
+ const resetForm = () => {
+ setSelectedFile(null)
+ setDocumentType('')
+ setTitle('')
+ setDescription('')
+ }
+
+ const loadDocuments = async () => {
+ setIsLoading(true)
+ try {
+ // 서버 액션 직접 호출
+ const docs = await getBiddingDocuments(biddingId)
+ const mappedDocs = docs.map((doc: any) => ({
+ ...doc,
+ uploadedAt: doc.uploadedAt?.toString() || '',
+ uploadedBy: doc.uploadedBy || ''
+ }))
+ setDocuments(mappedDocs)
+ } catch (error) {
+ console.error('Failed to load documents:', error)
+ toast({
+ title: '오류',
+ description: '문서 목록을 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleFileSelect = (event: React.ChangeEvent<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
+ }
+
+ setSelectedFile(file)
+ }
+
+ const handleUpload = async () => {
+ if (!selectedFile || !documentType) {
+ toast({
+ title: '입력 오류',
+ description: '파일과 문서 타입을 선택해주세요.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ // 서버 액션 직접 호출
+ const result = await uploadBiddingDocument(
+ biddingId,
+ selectedFile,
+ documentType,
+ title,
+ description,
+ userId
+ )
+
+ if (result.success) {
+ toast({
+ title: '업로드 완료',
+ description: result.message || '문서가 성공적으로 업로드되었습니다.',
+ })
+ resetForm()
+ await loadDocuments()
+ onSuccess?.()
+ } else {
+ toast({
+ title: '업로드 실패',
+ description: result.error || '문서 업로드에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('Upload error:', error)
+ toast({
+ title: '업로드 실패',
+ description: '문서 업로드 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ // 파일 다운로드
+ const handleDownload = (document: UploadedDocument) => {
+ startTransition(async () => {
+ try {
+ await downloadFile(document.filePath, document.originalFileName, {
+ showToast: true
+ })
+ } catch (error) {
+ toast({
+ title: '다운로드 실패',
+ description: '파일 다운로드에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ // 파일 삭제
+ const handleDelete = (document: UploadedDocument) => {
+ if (!confirm(`"${document.originalFileName}" 파일을 삭제하시겠습니까?`)) {
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ // 서버 액션 직접 호출
+ const result = await deleteBiddingDocument(document.id, biddingId, userId)
+
+ if (result.success) {
+ toast({
+ title: '삭제 완료',
+ description: result.message || '문서가 성공적으로 삭제되었습니다.',
+ })
+ await loadDocuments()
+ onSuccess?.()
+ } else {
+ toast({
+ title: '삭제 실패',
+ description: result.error || '문서 삭제에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('Delete error:', error)
+ toast({
+ title: '삭제 실패',
+ description: '문서 삭제 중 오류가 발생했습니다.',
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ // 파일 크기 포맷팅
+ const formatFileSize = (bytes: number | null) => {
+ if (!bytes) return '-'
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ const getDocumentTypeLabel = (type: string) => {
+ return documentTypes.find(dt => dt.value === type)?.label || type
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 입찰 문서 등록
+ </DialogTitle>
+ <DialogDescription>
+ 입찰 관련 문서를 업로드하고 관리할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 파일 업로드 섹션 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">새 문서 업로드</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="document-type">문서 타입 *</Label>
+ <Select value={documentType} onValueChange={setDocumentType}>
+ <SelectTrigger>
+ <SelectValue placeholder="문서 타입을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {documentTypes.map((type) => (
+ <SelectItem key={type.value} value={type.value}>
+ {type.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <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={handleFileSelect}
+ disabled={isPending}
+ />
+ <p className="text-xs text-muted-foreground">
+ 지원 형식: PDF, Word, Excel, 이미지, ZIP (최대 50MB)
+ </p>
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="title">제목</Label>
+ <Input
+ id="title"
+ placeholder="문서 제목을 입력하세요"
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="description">설명</Label>
+ <Textarea
+ id="description"
+ placeholder="문서에 대한 설명을 입력하세요"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ rows={3}
+ />
+ </div>
+
+ <div className="flex justify-end">
+ <Button
+ onClick={handleUpload}
+ disabled={!selectedFile || !documentType || isPending}
+ >
+ <Upload className="w-4 h-4 mr-2" />
+ 업로드
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 업로드된 문서 목록 */}
+ {isLoading ? (
+ <div className="text-center py-4">
+ <p className="text-muted-foreground">문서 목록을 불러오는 중...</p>
+ </div>
+ ) : documents.length > 0 ? (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ 업로드된 문서
+ <Badge variant="secondary">{documents.length}개</Badge>
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <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>
+ <Badge variant="outline">
+ {getDocumentTypeLabel(doc.documentType)}
+ </Badge>
+ </TableCell>
+ <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>
+ <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>
+ <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>
+ </CardContent>
+ </Card>
+ ) : (
+ <Card>
+ <CardContent className="text-center py-8">
+ <FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
+ <p className="text-gray-500">업로드된 문서가 없습니다</p>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index ed9d20e3..5240b134 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -5,9 +5,10 @@ import { type ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import {
+import {
Eye, Edit, MoreHorizontal, FileText, Users, Calendar,
- Building, Package, DollarSign, Clock, CheckCircle, XCircle
+ Building, Package, DollarSign, Clock, CheckCircle, XCircle,
+ AlertTriangle
} from "lucide-react"
import {
Tooltip,
@@ -136,6 +137,31 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
meta: { excelHeader: "입찰상태" },
},
+ // ░░░ 긴급여부 ░░░
+ {
+ accessorKey: "isUrgent",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="긴급여부" />,
+ cell: ({ row }) => {
+ const isUrgent = row.original.isUrgent
+
+ return isUrgent ? (
+ <div className="flex items-center gap-1">
+ <AlertTriangle className="h-4 w-4 text-red-600" />
+ <Badge variant="destructive" className="text-xs">
+ 긴급
+ </Badge>
+ </div>
+ ) : (
+ <div className="flex items-center gap-1">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="text-xs text-muted-foreground">일반</span>
+ </div>
+ )
+ },
+ size: 90,
+ meta: { excelHeader: "긴급여부" },
+ },
+
// ░░░ 사전견적 ░░░
{
id: "preQuote",
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index f21782ff..57cc1002 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -1793,6 +1793,29 @@ export function CreateBiddingDialog() {
<FormField
control={form.control}
+ name="isUrgent"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">
+ 긴급 입찰
+ </FormLabel>
+ <FormDescription>
+ 긴급 입찰 여부를 설정합니다
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
name="remarks"
render={({ field }) => (
<FormItem>
diff --git a/lib/bidding/list/edit-bidding-sheet.tsx b/lib/bidding/list/edit-bidding-sheet.tsx
index 71eeed2b..c76ec2a2 100644
--- a/lib/bidding/list/edit-bidding-sheet.tsx
+++ b/lib/bidding/list/edit-bidding-sheet.tsx
@@ -98,9 +98,11 @@ export function EditBiddingSheet({
budget: "",
targetPrice: "",
finalBidPrice: "",
+
+ isPublic: false,
+ isUrgent: false,
status: "bidding_generated",
- isPublic: false,
managerName: "",
managerEmail: "",
managerPhone: "",
@@ -143,6 +145,7 @@ export function EditBiddingSheet({
status: bidding.status || "bidding_generated",
isPublic: bidding.isPublic || false,
+ isUrgent: bidding.isUrgent || false,
managerName: bidding.managerName || "",
managerEmail: bidding.managerEmail || "",
managerPhone: bidding.managerPhone || "",
@@ -377,6 +380,54 @@ export function EditBiddingSheet({
</FormItem>
)}
/>
+
+ <div className="space-y-3">
+ <FormField
+ control={form.control}
+ name="isPublic"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-sm">
+ 공개 입찰
+ </FormLabel>
+ <FormDescription className="text-xs">
+ 공개 입찰 여부를 설정합니다
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="isUrgent"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-sm">
+ 긴급 입찰
+ </FormLabel>
+ <FormDescription className="text-xs">
+ 긴급 입찰 여부를 설정합니다
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+ </div>
</CardContent>
</Card>
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 7d314841..70458a15 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -554,6 +554,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
status: input.status || 'bidding_generated',
isPublic: input.isPublic || false,
+ isUrgent: input.isUrgent || false,
managerName: input.managerName,
managerEmail: input.managerEmail,
managerPhone: input.managerPhone,
@@ -807,6 +808,7 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) {
if (input.status !== undefined) updateData.status = input.status
if (input.isPublic !== undefined) updateData.isPublic = input.isPublic
+ if (input.isUrgent !== undefined) updateData.isUrgent = input.isUrgent
if (input.managerName !== undefined) updateData.managerName = input.managerName
if (input.managerEmail !== undefined) updateData.managerEmail = input.managerEmail
if (input.managerPhone !== undefined) updateData.managerPhone = input.managerPhone
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index a7f78f72..ab330596 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -95,6 +95,7 @@ export const createBiddingSchema = z.object({
// 상태 및 담당자
status: z.enum(biddings.status.enumValues).default("bidding_generated"),
isPublic: z.boolean().default(false),
+ isUrgent: z.boolean().default(false),
managerName: z.string().optional(),
managerEmail: z.string().email().optional().or(z.literal("")),
managerPhone: z.string().optional(),
@@ -157,6 +158,7 @@ export const createBiddingSchema = z.object({
status: z.enum(biddings.status.enumValues).optional(),
isPublic: z.boolean().optional(),
+ isUrgent: z.boolean().optional(),
managerName: z.string().optional(),
managerEmail: z.string().email().optional().or(z.literal("")),
managerPhone: z.string().optional(),
diff --git a/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
new file mode 100644
index 00000000..951923ca
--- /dev/null
+++ b/lib/bidding/vendor/partners-bidding-attachments-dialog.tsx
@@ -0,0 +1,246 @@
+'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,
+ Calendar,
+ User,
+ Paperclip
+} from 'lucide-react'
+import { useToast } from '@/hooks/use-toast'
+import { useTransition } from 'react'
+import { downloadFile } from '@/lib/file-download'
+import { getBiddingDocumentsForPartners } from '../detail/service'
+
+interface UploadedDocument {
+ id: number
+ biddingId: number
+ companyId: number | null
+ documentType: string
+ fileName: string
+ originalFileName: string
+ fileSize: number | null
+ filePath: string
+ title: string | null
+ description: string | null
+ uploadedAt: string
+ uploadedBy: string
+}
+
+interface PartnersBiddingAttachmentsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ biddingId: number
+ biddingTitle: string
+}
+
+const documentTypes = [
+ { value: 'notice', label: '입찰공고서' },
+ { value: 'specification', label: '사양서' },
+ { value: 'specification_meeting', label: '사양설명회' },
+ { value: 'contract_draft', label: '계약서 초안' },
+ { value: 'financial_doc', label: '재무 관련 문서' },
+ { value: 'technical_doc', label: '기술 관련 문서' },
+ { value: 'certificate', label: '인증서류' },
+ { value: 'pr_document', label: 'PR 문서' },
+ { value: 'spec_document', label: 'SPEC 문서' },
+ { value: 'evaluation_doc', label: '평가 관련 문서' },
+ { value: 'bid_attachment', label: '입찰 첨부파일' },
+ { value: 'other', label: '기타' }
+]
+
+export function PartnersBiddingAttachmentsDialog({
+ open,
+ onOpenChange,
+ biddingId,
+ biddingTitle
+}: PartnersBiddingAttachmentsDialogProps) {
+ 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])
+
+ const loadDocuments = async () => {
+ setIsLoading(true)
+ try {
+ const docs = await getBiddingDocumentsForPartners(biddingId)
+
+ // 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 = async (document: UploadedDocument) => {
+ startTransition(async () => {
+ try {
+ if (document.filePath && document.originalFileName) {
+ await downloadFile(document.filePath, document.originalFileName, {
+ showToast: true
+ })
+ } else {
+ throw new Error('파일 정보가 없습니다.')
+ }
+ } catch (error) {
+ toast({
+ title: '다운로드 실패',
+ description: '파일 다운로드에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ })
+ }
+
+ // 파일 크기 포맷팅
+ const formatFileSize = (bytes: number | null) => {
+ if (!bytes) return '-'
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ const getDocumentTypeLabel = (type: string) => {
+ return documentTypes.find(dt => dt.value === type)?.label || type
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Paperclip className="w-5 h-5" />
+ <span>입찰 첨부파일</span>
+ <span className="text-sm font-normal text-muted-foreground">
+ - {biddingTitle}
+ </span>
+ </DialogTitle>
+ <DialogDescription>
+ SHI에서 제공한 입찰 관련 첨부파일 목록입니다.
+ </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>작성자</TableHead>
+ <TableHead className="w-24">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {documents.map((doc) => (
+ <TableRow key={doc.id}>
+ <TableCell>
+ <Badge variant="outline">
+ {getDocumentTypeLabel(doc.documentType)}
+ </Badge>
+ </TableCell>
+ <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">
+ <Paperclip className="w-12 h-12 mx-auto mb-4 opacity-50" />
+ <p className="text-lg font-medium mb-2">첨부파일이 없습니다</p>
+ <p className="text-sm">SHI에서 제공한 첨부파일이 아직 없습니다.</p>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 7058f026..431f7e9a 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -18,7 +18,9 @@ import {
MoreHorizontal,
Calendar,
User,
- Calculator
+ Calculator,
+ Paperclip,
+ AlertTriangle
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
@@ -91,6 +93,59 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
},
}),
+ // 긴급여부
+ columnHelper.accessor('isUrgent', {
+ header: '긴급여부',
+ cell: ({ row }) => {
+ const isUrgent = row.original.isUrgent
+
+ return isUrgent ? (
+ <div className="flex items-center gap-1">
+ <AlertTriangle className="h-4 w-4 text-red-600" />
+ <Badge variant="destructive" className="text-xs">
+ 긴급
+ </Badge>
+ </div>
+ ) : (
+ <div className="flex items-center gap-1">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="text-xs text-muted-foreground">일반</span>
+ </div>
+ )
+ },
+ }),
+
+ // 첨부파일
+ columnHelper.display({
+ id: 'attachments',
+ header: 'SHI 첨부파일',
+ cell: ({ row }) => {
+ const handleViewDocumentsClick = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (setRowAction) {
+ setRowAction({
+ type: 'view-documents',
+ row: { original: row.original }
+ })
+ }
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="p-1 h-8 w-8"
+ onClick={handleViewDocumentsClick}
+ title="첨부파일 보기"
+ >
+ <Paperclip className="h-4 w-4 text-blue-600" />
+ </Button>
+ )
+ },
+ size: 80,
+ enableSorting: false,
+ }),
+
// 액션 (드롭다운 메뉴)
columnHelper.display({
id: 'actions',
diff --git a/lib/bidding/vendor/partners-bidding-list.tsx b/lib/bidding/vendor/partners-bidding-list.tsx
index 2fcf1bab..eb38ce71 100644
--- a/lib/bidding/vendor/partners-bidding-list.tsx
+++ b/lib/bidding/vendor/partners-bidding-list.tsx
@@ -17,6 +17,7 @@ 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 { PartnersBiddingAttachmentsDialog } from './partners-bidding-attachments-dialog'
import { setPreQuoteParticipation, getBiddingCompaniesForPartners } from '../pre-quote/service'
interface PartnersBiddingListProps {
@@ -31,6 +32,8 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
const [isParticipationDialogOpen, setIsParticipationDialogOpen] = React.useState(false)
const [selectedBiddingForParticipation, setSelectedBiddingForParticipation] = React.useState<PartnersBiddingListItem | null>(null)
const [selectedBiddingForPreQuoteParticipation, setSelectedBiddingForPreQuoteParticipation] = React.useState<any | null>(null)
+ const [isAttachmentsDialogOpen, setIsAttachmentsDialogOpen] = React.useState(false)
+ const [selectedBiddingForAttachments, setSelectedBiddingForAttachments] = React.useState<PartnersBiddingListItem | null>(null)
const router = useRouter()
const { toast } = useToast()
@@ -124,6 +127,12 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
handlePreQuoteParticipationDecision(true)
setRowAction(null) // rowAction 초기화
break
+ case 'view-documents':
+ // 첨부파일 다이얼로그 열기
+ setSelectedBiddingForAttachments(rowAction.row.original)
+ setIsAttachmentsDialogOpen(true)
+ setRowAction(null) // rowAction 초기화
+ break
default:
break
}
@@ -255,6 +264,13 @@ export function PartnersBiddingList({ companyId }: PartnersBiddingListProps) {
}}
/> */}
+ <PartnersBiddingAttachmentsDialog
+ open={isAttachmentsDialogOpen}
+ onOpenChange={setIsAttachmentsDialogOpen}
+ biddingId={selectedBiddingForAttachments?.biddingId || 0}
+ biddingTitle={selectedBiddingForAttachments?.title || ''}
+ />
+
</>
)
}