diff options
| -rw-r--r-- | lib/bidding/detail/service.ts | 234 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx | 24 | ||||
| -rw-r--r-- | lib/bidding/detail/table/bidding-document-upload-dialog.tsx | 462 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-columns.tsx | 30 | ||||
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 23 | ||||
| -rw-r--r-- | lib/bidding/list/edit-bidding-sheet.tsx | 53 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 2 | ||||
| -rw-r--r-- | lib/bidding/validation.ts | 2 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-attachments-dialog.tsx | 246 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list-columns.tsx | 57 | ||||
| -rw-r--r-- | lib/bidding/vendor/partners-bidding-list.tsx | 16 |
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 || ''} + /> + </> ) } |
