diff options
Diffstat (limited to 'lib/bidding')
| -rw-r--r-- | lib/bidding/list/bidding-detail-dialogs.tsx | 754 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-page-header.tsx | 2 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-columns.tsx | 8 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table.tsx | 54 | ||||
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 2096 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 424 | ||||
| -rw-r--r-- | lib/bidding/validation.ts | 4 |
7 files changed, 2298 insertions, 1044 deletions
diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx new file mode 100644 index 00000000..2e58d676 --- /dev/null +++ b/lib/bidding/list/bidding-detail-dialogs.tsx @@ -0,0 +1,754 @@ +"use client" + +import * as React from "react" +import { useState, useEffect } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { + CalendarIcon, + ClockIcon, + MapPinIcon, + FileTextIcon, + DownloadIcon, + EyeIcon, + PackageIcon, + HashIcon, + DollarSignIcon, + WeightIcon, + ExternalLinkIcon +} from "lucide-react" +import { toast } from "sonner" +import { BiddingListItem } from "@/db/schema" +import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download" +import { getPRDetailsAction, getSpecificationMeetingDetailsAction } from "../service" + +// 타입 정의 +interface SpecificationMeetingDetails { + id: number; + biddingId: number; + meetingDate: string; + meetingTime: string | null; + location: string | null; + address: string | null; + contactPerson: string | null; + contactPhone: string | null; + contactEmail: string | null; + agenda: string | null; + materials: string | null; + notes: string | null; + isRequired: boolean; + createdAt: string; + updatedAt: string; + documents: Array<{ + id: number; + fileName: string; + originalFileName: string; + fileSize: number; + filePath: string; + title: string | null; + uploadedAt: string; + uploadedBy: string | null; + }>; +} + +interface PRDetails { + documents: Array<{ + id: number; + documentName: string; + fileName: string; + originalFileName: string; + fileSize: number; + filePath: string; + registeredAt: string; + registeredBy: string | null; + version: string | null; + description: string | null; + createdAt: string; + updatedAt: string; + }>; + items: Array<{ + id: number; + itemNumber: string; + itemInfo: string | null; + quantity: number | null; + quantityUnit: string | null; + requestedDeliveryDate: string | null; + prNumber: string | null; + annualUnitPrice: number | null; + currency: string | null; + totalWeight: number | null; + weightUnit: string | null; + materialDescription: string | null; + hasSpecDocument: boolean; + createdAt: string; + updatedAt: string; + specDocuments: Array<{ + id: number; + fileName: string; + originalFileName: string; + fileSize: number; + filePath: string; + uploadedAt: string; + title: string | null; + }>; + }>; +} + +interface ActionResult<T> { + success: boolean; + data?: T; + error?: string; +} + +// 파일 다운로드 훅 +const useFileDownload = () => { + const [downloadingFiles, setDownloadingFiles] = useState<Set<string>>(new Set()); + + const handleDownload = async (filePath: string, fileName: string, options?: { + action?: 'download' | 'preview' + }) => { + const fileKey = `${filePath}_${fileName}`; + if (downloadingFiles.has(fileKey)) return; + + setDownloadingFiles(prev => new Set(prev).add(fileKey)); + + try { + await downloadFile(filePath, fileName, { + action: options?.action || 'download', + showToast: true, + showSuccessToast: true, + onError: (error) => { + console.error("파일 다운로드 실패:", error); + }, + onSuccess: (fileName, fileSize) => { + console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : ''); + } + }); + } catch (error) { + console.error("다운로드 처리 중 오류:", error); + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev); + newSet.delete(fileKey); + return newSet; + }); + } + }; + + return { handleDownload, downloadingFiles }; +}; + +// 파일 링크 컴포넌트 +interface FileDownloadLinkProps { + filePath: string; + fileName: string; + fileSize?: number; + title?: string | null; + className?: string; +} + +const FileDownloadLink: React.FC<FileDownloadLinkProps> = ({ + filePath, + fileName, + fileSize, + title, + className = "" +}) => { + const { handleDownload, downloadingFiles } = useFileDownload(); + const fileInfo = getFileInfo(fileName); + const fileKey = `${filePath}_${fileName}`; + const isDownloading = downloadingFiles.has(fileKey); + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <button + onClick={() => handleDownload(filePath, fileName)} + disabled={isDownloading} + className={`inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 hover:underline disabled:opacity-50 disabled:cursor-not-allowed ${className}`} + > + <span className="text-xs">{fileInfo.icon}</span> + <span className="truncate max-w-[150px]"> + {isDownloading ? "다운로드 중..." : (title || fileName)} + </span> + <ExternalLinkIcon className="h-3 w-3 opacity-60" /> + </button> + </TooltipTrigger> + <TooltipContent> + <div className="text-xs"> + <div className="font-medium">{fileName}</div> + {fileSize && <div className="text-muted-foreground">{formatFileSize(fileSize)}</div>} + <div className="text-muted-foreground">클릭하여 다운로드</div> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); +}; + +// 파일 다운로드 버튼 컴포넌트 (간소화된 버전) +interface FileDownloadButtonProps { + filePath: string; + fileName: string; + fileSize?: number; + title?: string | null; + variant?: "download" | "preview"; + size?: "sm" | "default" | "lg"; +} + +const FileDownloadButton: React.FC<FileDownloadButtonProps> = ({ + filePath, + fileName, + fileSize, + title, + variant = "download", + size = "sm" +}) => { + const { handleDownload, downloadingFiles } = useFileDownload(); + const fileInfo = getFileInfo(fileName); + const fileKey = `${filePath}_${fileName}`; + const isDownloading = downloadingFiles.has(fileKey); + + const Icon = variant === "preview" && fileInfo.canPreview ? EyeIcon : DownloadIcon; + + return ( + <Button + onClick={() => handleDownload(filePath, fileName, { action: variant })} + disabled={isDownloading} + size={size} + variant="outline" + className="gap-2" + > + <Icon className="h-4 w-4" /> + {isDownloading ? "처리중..." : ( + variant === "preview" && fileInfo.canPreview ? "미리보기" : "다운로드" + )} + {fileSize && size !== "sm" && ( + <span className="text-xs text-muted-foreground"> + ({formatFileSize(fileSize)}) + </span> + )} + </Button> + ); +}; + +// 사양설명회 다이얼로그 +interface SpecificationMeetingDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bidding: BiddingListItem | null; +} + +export function SpecificationMeetingDialog({ + open, + onOpenChange, + bidding +}: SpecificationMeetingDialogProps) { + const [data, setData] = useState<SpecificationMeetingDetails | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (open && bidding) { + fetchSpecificationMeetingData(); + } + }, [open, bidding]); + + const fetchSpecificationMeetingData = async () => { + if (!bidding) return; + + setLoading(true); + setError(null); + + try { + const result = await getSpecificationMeetingDetailsAction(bidding.id); + + if (result.success && result.data) { + setData(result.data); + } else { + setError(result.error || "사양설명회 정보를 불러올 수 없습니다."); + } + } catch (err) { + setError("데이터 로딩 중 오류가 발생했습니다."); + console.error("Failed to fetch specification meeting data:", err); + } finally { + setLoading(false); + } + }; + + const formatDate = (dateString: string) => { + try { + return new Date(dateString).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long' + }); + } catch { + return dateString; + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <CalendarIcon className="h-5 w-5" /> + 사양설명회 정보 + </DialogTitle> + <DialogDescription> + {bidding?.title}의 사양설명회 상세 정보입니다. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="max-h-[75vh]"> + {loading ? ( + <div className="flex items-center justify-center py-6"> + <div className="text-center"> + <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto mb-2"></div> + <p className="text-sm text-muted-foreground">로딩 중...</p> + </div> + </div> + ) : error ? ( + <div className="flex items-center justify-center py-6"> + <div className="text-center"> + <p className="text-sm text-destructive mb-2">{error}</p> + <Button onClick={fetchSpecificationMeetingData} size="sm"> + 다시 시도 + </Button> + </div> + </div> + ) : data ? ( + <div className="space-y-4"> + {/* 기본 정보 */} + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">기본 정보</CardTitle> + </CardHeader> + <CardContent className="pt-0"> + <div className="text-sm space-y-1"> + <div> + <CalendarIcon className="inline h-3 w-3 text-muted-foreground mr-2" /> + <span className="font-medium">날짜:</span> {formatDate(data.meetingDate)} + {data.meetingTime && <span className="ml-4"><ClockIcon className="inline h-3 w-3 text-muted-foreground mr-1" />{data.meetingTime}</span>} + </div> + + {data.location && ( + <div> + <MapPinIcon className="inline h-3 w-3 text-muted-foreground mr-2" /> + <span className="font-medium">장소:</span> {data.location} + {data.address && <span className="text-muted-foreground ml-2">({data.address})</span>} + </div> + )} + + <div> + <span className="font-medium">참석 필수:</span> + <Badge variant={data.isRequired ? "destructive" : "secondary"} className="text-xs ml-2"> + {data.isRequired ? "필수" : "선택"} + </Badge> + </div> + </div> + </CardContent> + </Card> + + {/* 연락처 정보 */} + {(data.contactPerson || data.contactPhone || data.contactEmail) && ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">연락처 정보</CardTitle> + </CardHeader> + <CardContent className="pt-0"> + <div className="text-sm"> + {[ + data.contactPerson && `담당자: ${data.contactPerson}`, + data.contactPhone && `전화: ${data.contactPhone}`, + data.contactEmail && `이메일: ${data.contactEmail}` + ].filter(Boolean).join(' • ')} + </div> + </CardContent> + </Card> + )} + + {/* 안건 및 준비물 */} + {(data.agenda || data.materials) && ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">안건 및 준비물</CardTitle> + </CardHeader> + <CardContent className="pt-0 space-y-3"> + {data.agenda && ( + <div> + <span className="font-medium text-sm">안건:</span> + <div className="mt-1 p-2 bg-muted rounded text-sm whitespace-pre-wrap"> + {data.agenda} + </div> + </div> + )} + + {data.materials && ( + <div> + <span className="font-medium text-sm">준비물:</span> + <div className="mt-1 p-2 bg-muted rounded text-sm whitespace-pre-wrap"> + {data.materials} + </div> + </div> + )} + </CardContent> + </Card> + )} + + {/* 비고 */} + {data.notes && ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base">비고</CardTitle> + </CardHeader> + <CardContent className="pt-0"> + <div className="p-2 bg-muted rounded text-sm whitespace-pre-wrap"> + {data.notes} + </div> + </CardContent> + </Card> + )} + + {/* 관련 문서 */} + {data.documents.length > 0 && ( + <Card> + <CardHeader className="pb-3"> + <CardTitle className="text-base flex items-center gap-2"> + <FileTextIcon className="h-4 w-4" /> + 관련 문서 ({data.documents.length}개) + </CardTitle> + </CardHeader> + <CardContent className="pt-0"> + <div className="space-y-2"> + {data.documents.map((doc) => ( + <div key={doc.id} className="flex items-center gap-2"> + <FileDownloadLink + filePath={doc.filePath} + fileName={doc.originalFileName} + fileSize={doc.fileSize} + title={doc.title} + /> + </div> + ))} + </div> + </CardContent> + </Card> + )} + </div> + ) : null} + </ScrollArea> + </DialogContent> + </Dialog> + ); +} + +// PR 문서 다이얼로그 +interface PrDocumentsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bidding: BiddingListItem | null; +} + +export function PrDocumentsDialog({ + open, + onOpenChange, + bidding +}: PrDocumentsDialogProps) { + const [data, setData] = useState<PRDetails | null>(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + if (open && bidding) { + fetchPRData(); + } + }, [open, bidding]); + + const fetchPRData = async () => { + if (!bidding) return; + + setLoading(true); + setError(null); + + try { + const result = await getPRDetailsAction(bidding.id); + + if (result.success && result.data) { + setData(result.data); + } else { + setError(result.error || "PR 문서 정보를 불러올 수 없습니다."); + } + } catch (err) { + setError("데이터 로딩 중 오류가 발생했습니다."); + console.error("Failed to fetch PR data:", err); + } finally { + setLoading(false); + } + }; + + const formatCurrency = (amount: number | null, currency: string | null) => { + if (amount === null) return "-"; + return `${amount.toLocaleString()} ${currency || ""}`; + }; + + const formatWeight = (weight: number | null, unit: string | null) => { + if (weight === null) return "-"; + return `${weight.toLocaleString()} ${unit || ""}`; + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-7xl max-h-[90vh]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <PackageIcon className="h-5 w-5" /> + PR 문서 + </DialogTitle> + <DialogDescription> + {bidding?.title}의 PR 문서 및 아이템 정보입니다. + </DialogDescription> + </DialogHeader> + + <ScrollArea className="max-h-[75vh]"> + {loading ? ( + <div className="flex items-center justify-center py-8"> + <div className="text-center"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div> + <p className="text-sm text-muted-foreground">로딩 중...</p> + </div> + </div> + ) : error ? ( + <div className="flex items-center justify-center py-8"> + <div className="text-center"> + <p className="text-sm text-destructive mb-2">{error}</p> + <Button onClick={fetchPRData} size="sm"> + 다시 시도 + </Button> + </div> + </div> + ) : data ? ( + <div className="space-y-6"> + {/* PR 문서 목록 */} + {data.documents.length > 0 && ( + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <FileTextIcon className="h-5 w-5" /> + PR 문서 ({data.documents.length}개) + </CardTitle> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead>문서명</TableHead> + <TableHead>파일명</TableHead> + <TableHead>버전</TableHead> + <TableHead>크기</TableHead> + <TableHead>등록일</TableHead> + <TableHead>등록자</TableHead> + <TableHead className="text-right">다운로드</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {data.documents.map((doc) => ( + <TableRow key={doc.id}> + <TableCell className="font-medium"> + {doc.documentName} + {doc.description && ( + <div className="text-xs text-muted-foreground mt-1"> + {doc.description} + </div> + )} + </TableCell> + <TableCell> + <FileDownloadLink + filePath={doc.filePath} + fileName={doc.originalFileName} + fileSize={doc.fileSize} + /> + </TableCell> + <TableCell> + {doc.version ? ( + <Badge variant="outline">{doc.version}</Badge> + ) : "-"} + </TableCell> + <TableCell>{formatFileSize(doc.fileSize)}</TableCell> + <TableCell> + {new Date(doc.registeredAt).toLocaleDateString('ko-KR')} + </TableCell> + <TableCell>{doc.registeredBy || "-"}</TableCell> + <TableCell className="text-right"> + <FileDownloadButton + filePath={doc.filePath} + fileName={doc.originalFileName} + fileSize={doc.fileSize} + variant="download" + size="sm" + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + )} + + {/* PR 아이템 테이블 */} + {data.items.length > 0 && ( + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <HashIcon className="h-5 w-5" /> + PR 아이템 ({data.items.length}개) + </CardTitle> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[100px]">아이템 번호</TableHead> + <TableHead className="w-[150px]">PR 번호</TableHead> + <TableHead>아이템 정보</TableHead> + <TableHead className="w-[120px]">수량</TableHead> + <TableHead className="w-[120px]">단가</TableHead> + <TableHead className="w-[120px]">중량</TableHead> + <TableHead className="w-[120px]">요청 납기</TableHead> + <TableHead className="w-[200px]">스펙 문서</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {data.items.map((item) => ( + <TableRow key={item.id}> + <TableCell className="font-medium"> + {item.itemNumber} + </TableCell> + <TableCell> + {item.prNumber || "-"} + </TableCell> + <TableCell> + <div> + {item.itemInfo && ( + <div className="font-medium text-sm mb-1">{item.itemInfo}</div> + )} + {item.materialDescription && ( + <div className="text-xs text-muted-foreground"> + {item.materialDescription} + </div> + )} + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <PackageIcon className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {item.quantity ? `${item.quantity.toLocaleString()} ${item.quantityUnit || ""}` : "-"} + </span> + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <DollarSignIcon className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {formatCurrency(item.annualUnitPrice, item.currency)} + </span> + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <WeightIcon className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {formatWeight(item.totalWeight, item.weightUnit)} + </span> + </div> + </TableCell> + <TableCell> + {item.requestedDeliveryDate ? ( + <div className="flex items-center gap-1"> + <CalendarIcon className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {new Date(item.requestedDeliveryDate).toLocaleDateString('ko-KR')} + </span> + </div> + ) : "-"} + </TableCell> + <TableCell> + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <Badge variant={item.hasSpecDocument ? "default" : "secondary"} className="text-xs"> + {item.hasSpecDocument ? "있음" : "없음"} + </Badge> + {item.specDocuments.length > 0 && ( + <span className="text-xs text-muted-foreground"> + ({item.specDocuments.length}개) + </span> + )} + </div> + {item.specDocuments.length > 0 && ( + <div className="space-y-1"> + {item.specDocuments.map((doc, index) => ( + <div key={doc.id} className="text-xs"> + <FileDownloadLink + filePath={doc.filePath} + fileName={doc.originalFileName} + fileSize={doc.fileSize} + title={doc.title} + className="text-xs" + /> + </div> + ))} + </div> + )} + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + )} + + {/* 데이터가 없는 경우 */} + {data.documents.length === 0 && data.items.length === 0 && ( + <div className="text-center py-8"> + <FileTextIcon className="h-12 w-12 text-muted-foreground mx-auto mb-2" /> + <p className="text-muted-foreground">PR 문서가 없습니다.</p> + </div> + )} + </div> + ) : null} + </ScrollArea> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/bidding/list/biddings-page-header.tsx b/lib/bidding/list/biddings-page-header.tsx index ece29e07..7fa9a39c 100644 --- a/lib/bidding/list/biddings-page-header.tsx +++ b/lib/bidding/list/biddings-page-header.tsx @@ -11,7 +11,7 @@ export function BiddingsPageHeader() { <div className="flex items-center justify-between"> {/* 좌측: 제목과 설명 */} <div className="space-y-1"> - <h1 className="text-3xl font-bold tracking-tight">입찰 목록 관리</h1> + <h2 className="text-3xl font-bold tracking-tight">입찰 목록 관리</h2> <p className="text-muted-foreground"> 입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다. </p> diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx index 34fc574e..fde77bfb 100644 --- a/lib/bidding/list/biddings-table-columns.tsx +++ b/lib/bidding/list/biddings-table-columns.tsx @@ -270,7 +270,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef accessorKey: "contractPeriod", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />, cell: ({ row }) => ( - <span className="text-sm">{row.original.contractPeriod || '-'}</span> + <span className="truncate max-w-[100px]">{row.original.contractPeriod || '-'}</span> ), size: 100, meta: { excelHeader: "계약기간" }, @@ -401,7 +401,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정" />, cell: ({ row }) => ( <Badge variant="outline" className="font-mono"> - {row.original.participantStats.expected} + {row.original.participantExpected} </Badge> ), size: 80, @@ -413,7 +413,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여" />, cell: ({ row }) => ( <Badge variant="default" className="font-mono"> - {row.original.participantStats.participated} + {row.original.participantParticipated} </Badge> ), size: 60, @@ -425,7 +425,7 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기" />, cell: ({ row }) => ( <Badge variant="destructive" className="font-mono"> - {row.original.participantStats.declined} + {row.original.participantDeclined} </Badge> ), size: 60, diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx index ce4aade9..672b756b 100644 --- a/lib/bidding/list/biddings-table.tsx +++ b/lib/bidding/list/biddings-table.tsx @@ -21,6 +21,8 @@ import { biddingTypeLabels } from "@/db/schema" import { EditBiddingSheet } from "./edit-bidding-sheet" +import { SpecificationMeetingDialog, PrDocumentsDialog } from "./bidding-detail-dialogs" + interface BiddingsTableProps { promises: Promise< @@ -34,6 +36,11 @@ interface BiddingsTableProps { export function BiddingsTable({ promises }: BiddingsTableProps) { const [{ data, pageCount }, statusCounts] = React.use(promises) const [isCompact, setIsCompact] = React.useState<boolean>(false) + const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false) + const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false) + const [selectedBidding, setSelectedBidding] = React.useState<BiddingListItem | null>(null) + + console.log(data,"data") const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingListItem> | null>(null) @@ -44,6 +51,25 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { [setRowAction] ) + // rowAction 변경 감지하여 해당 다이얼로그 열기 + React.useEffect(() => { + if (rowAction) { + setSelectedBidding(rowAction.row.original) + + switch (rowAction.type) { + case "specification_meeting": + setSpecMeetingDialogOpen(true) + break + case "pr_documents": + setPrDocumentsDialogOpen(true) + break + // 기존 다른 액션들은 그대로 유지 + default: + break + } + } + }, [rowAction]) + const filterFields: DataTableFilterField<BiddingListItem>[] = [] const advancedFilterFields: DataTableAdvancedFilterField<BiddingListItem>[] = [ @@ -104,6 +130,18 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { }, []) + const handleSpecMeetingDialogClose = React.useCallback(() => { + setSpecMeetingDialogOpen(false) + setRowAction(null) + setSelectedBidding(null) + }, []) + + const handlePrDocumentsDialogClose = React.useCallback(() => { + setPrDocumentsDialogOpen(false) + setRowAction(null) + setSelectedBidding(null) + }, []) + return ( <> @@ -129,7 +167,21 @@ export function BiddingsTable({ promises }: BiddingsTableProps) { bidding={rowAction?.row.original} onSuccess={() => router.refresh()} /> + + {/* 사양설명회 다이얼로그 */} + <SpecificationMeetingDialog + open={specMeetingDialogOpen} + onOpenChange={handleSpecMeetingDialogClose} + bidding={selectedBidding} + /> + + {/* PR 문서 다이얼로그 */} + <PrDocumentsDialog + open={prDocumentsDialogOpen} + onOpenChange={handlePrDocumentsDialogClose} + bidding={selectedBidding} + /> </> ) -}
\ No newline at end of file +}
\ No newline at end of file diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index 683f6aff..90204dc9 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -79,6 +79,16 @@ import { awardCountLabels } from "@/db/schema" import { ProjectSelector } from "@/components/ProjectSelector" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" // 사양설명회 정보 타입 interface SpecificationMeetingInfo { @@ -119,6 +129,8 @@ export function CreateBiddingDialog() { const { data: session } = useSession() const [open, setOpen] = React.useState(false) const [activeTab, setActiveTab] = React.useState<TabType>("basic") + const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // 추가 + const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null) // 추가 // 사양설명회 정보 상태 const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({ @@ -372,13 +384,12 @@ export function CreateBiddingDialog() { const handleProjectSelect = React.useCallback((project: { id: number; code: string; name: string } | null) => { if (project) { form.setValue("projectId", project.id) - form.setValue("projectName", `${project.code} (${project.name})`) } else { form.setValue("projectId", 0) - form.setValue("projectName", "") } }, [form]) + // 다음 버튼 클릭 핸들러 const handleNextClick = () => { // 현재 탭 validation 체크 @@ -444,11 +455,8 @@ export function CreateBiddingDialog() { // 생성된 입찰 상세페이지로 이동할지 묻기 if (result.data?.id) { - setTimeout(() => { - if (confirm("생성된 입찰의 상세페이지로 이동하시겠습니까?")) { - router.push(`/admin/biddings/${result.data.id}`) - } - }, 500) + setCreatedBiddingId(result.data.id) + setShowSuccessDialog(true) } } else { toast.error(result.error || "입찰 생성에 실패했습니다.") @@ -510,6 +518,8 @@ export function CreateBiddingDialog() { setPrItems([]) setSelectedItemForFile(null) setActiveTab("basic") + setShowSuccessDialog(false) // 추가 + setCreatedBiddingId(null) // 추가 }, [form]) // 다이얼로그 핸들러 @@ -520,101 +530,109 @@ export function CreateBiddingDialog() { setOpen(nextOpen) } - return ( - <Dialog open={open} onOpenChange={handleDialogOpenChange}> - <DialogTrigger asChild> - <Button variant="default" size="sm"> - 신규 입찰 - </Button> - </DialogTrigger> - <DialogContent className="max-w-6xl h-[90vh] p-0 flex flex-col"> - {/* 고정 헤더 */} - <div className="flex-shrink-0 p-6 border-b"> - <DialogHeader> - <DialogTitle>신규 입찰 생성</DialogTitle> - <DialogDescription> - 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요. - </DialogDescription> - </DialogHeader> - </div> - - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col flex-1 min-h-0" - id="create-bidding-form" - > - {/* 탭 영역 */} - <div className="flex-1 overflow-hidden"> - <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col"> - <div className="px-6 pt-4"> - <TabsList className="grid w-full grid-cols-5"> - <TabsTrigger value="basic" className="relative"> - 기본 정보 - {!tabValidation.basic.isValid && ( - <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> - )} - </TabsTrigger> - <TabsTrigger value="contract" className="relative"> - 계약 정보 - {!tabValidation.contract.isValid && ( - <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> - )} - </TabsTrigger> - <TabsTrigger value="schedule" className="relative"> - 일정 & 회의 - {!tabValidation.schedule.isValid && ( - <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> - )} - </TabsTrigger> - <TabsTrigger value="details">세부내역</TabsTrigger> - <TabsTrigger value="manager">담당자 & 기타</TabsTrigger> - </TabsList> - </div> + // 입찰 생성 버튼 클릭 핸들러 추가 + const handleCreateBidding = () => { + // 마지막 탭 validation 체크 + if (!isCurrentTabValid()) { + toast.error("필수 정보를 모두 입력해주세요.") + return + } - <div className="flex-1 overflow-y-auto p-6"> - {/* 기본 정보 탭 */} - <TabsContent value="basic" className="mt-0 space-y-6"> - <Card> - <CardHeader> - <CardTitle>기본 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - {/* 프로젝트 선택 */} - <FormField - control={form.control} - name="projectId" - render={({ field }) => ( - <FormItem> - <FormLabel> - 프로젝트 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + // 수동으로 폼 제출 + form.handleSubmit(onSubmit)() + } - <div className="grid grid-cols-2 gap-6"> - {/* 품목명 */} + // 성공 다이얼로그 핸들러들 + const handleNavigateToDetail = () => { + if (createdBiddingId) { + router.push(`/evcp/biddings/${createdBiddingId}`) + } + setShowSuccessDialog(false) + setCreatedBiddingId(null) + } + + const handleStayOnPage = () => { + setShowSuccessDialog(false) + setCreatedBiddingId(null) + } + + + return ( + <> + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <Plus className="mr-2 h-4 w-4" /> + 신규 입찰 + </Button> + </DialogTrigger> + <DialogContent className="max-w-6xl h-[90vh] p-0 flex flex-col"> + {/* 고정 헤더 */} + <div className="flex-shrink-0 p-6 border-b"> + <DialogHeader> + <DialogTitle>신규 입찰 생성</DialogTitle> + <DialogDescription> + 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + </div> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col flex-1 min-h-0" + id="create-bidding-form" + > + {/* 탭 영역 */} + <div className="flex-1 overflow-hidden"> + <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col"> + <div className="px-6 pt-4"> + <TabsList className="grid w-full grid-cols-5"> + <TabsTrigger value="basic" className="relative"> + 기본 정보 + {!tabValidation.basic.isValid && ( + <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> + )} + </TabsTrigger> + <TabsTrigger value="contract" className="relative"> + 계약 정보 + {!tabValidation.contract.isValid && ( + <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> + )} + </TabsTrigger> + <TabsTrigger value="schedule" className="relative"> + 일정 & 회의 + {!tabValidation.schedule.isValid && ( + <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span> + )} + </TabsTrigger> + <TabsTrigger value="details">세부내역</TabsTrigger> + <TabsTrigger value="manager">담당자 & 기타</TabsTrigger> + </TabsList> + </div> + + <div className="flex-1 overflow-y-auto p-6"> + {/* 기본 정보 탭 */} + <TabsContent value="basic" className="mt-0 space-y-6"> + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + {/* 프로젝트 선택 */} <FormField control={form.control} - name="itemName" + name="projectId" render={({ field }) => ( <FormItem> <FormLabel> - 품목명 <span className="text-red-500">*</span> + 프로젝트 <span className="text-red-500">*</span> </FormLabel> <FormControl> - <Input - placeholder="품목명" - {...field} + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." /> </FormControl> <FormMessage /> @@ -622,156 +640,232 @@ export function CreateBiddingDialog() { )} /> - {/* 리비전 */} + <div className="grid grid-cols-2 gap-6"> + {/* 품목명 */} + <FormField + control={form.control} + name="itemName" + render={({ field }) => ( + <FormItem> + <FormLabel> + 품목명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="품목명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 리비전 */} + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel>리비전</FormLabel> + <FormControl> + <Input + type="number" + min="0" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 입찰명 */} <FormField control={form.control} - name="revision" + name="title" render={({ field }) => ( <FormItem> - <FormLabel>리비전</FormLabel> + <FormLabel> + 입찰명 <span className="text-red-500">*</span> + </FormLabel> <FormControl> <Input - type="number" - min="0" + placeholder="입찰명을 입력하세요" {...field} - onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} /> </FormControl> <FormMessage /> </FormItem> )} /> - </div> - - {/* 입찰명 */} - <FormField - control={form.control} - name="title" - render={({ field }) => ( - <FormItem> - <FormLabel> - 입찰명 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <Input - placeholder="입찰명을 입력하세요" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 설명 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <FormLabel>설명</FormLabel> - <FormControl> - <Textarea - placeholder="입찰에 대한 설명을 입력하세요" - rows={4} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - </Card> - </TabsContent> - - {/* 계약 정보 탭 */} - <TabsContent value="contract" className="mt-0 space-y-6"> - <Card> - <CardHeader> - <CardTitle>계약 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <div className="grid grid-cols-2 gap-6"> - {/* 계약구분 */} + + {/* 설명 */} <FormField control={form.control} - name="contractType" + name="description" render={({ field }) => ( <FormItem> - <FormLabel> - 계약구분 <span className="text-red-500">*</span> - </FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="계약구분 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - {Object.entries(contractTypeLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea + placeholder="입찰에 대한 설명을 입력하세요" + rows={4} + {...field} + /> + </FormControl> <FormMessage /> </FormItem> )} /> + </CardContent> + </Card> + </TabsContent> + + {/* 계약 정보 탭 */} + <TabsContent value="contract" className="mt-0 space-y-6"> + <Card> + <CardHeader> + <CardTitle>계약 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <div className="grid grid-cols-2 gap-6"> + {/* 계약구분 */} + <FormField + control={form.control} + name="contractType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 계약구분 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="계약구분 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(contractTypeLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 입찰유형 */} + <FormField + control={form.control} + name="biddingType" + render={({ field }) => ( + <FormItem> + <FormLabel> + 입찰유형 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="입찰유형 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(biddingTypeLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> - {/* 입찰유형 */} - <FormField - control={form.control} - name="biddingType" - render={({ field }) => ( - <FormItem> - <FormLabel> - 입찰유형 <span className="text-red-500">*</span> - </FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> + <div className="grid grid-cols-2 gap-6"> + {/* 낙찰수 */} + <FormField + control={form.control} + name="awardCount" + render={({ field }) => ( + <FormItem> + <FormLabel> + 낙찰수 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="낙찰수 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {Object.entries(awardCountLabels).map(([value, label]) => ( + <SelectItem key={value} value={value}> + {label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 계약기간 */} + <FormField + control={form.control} + name="contractPeriod" + render={({ field }) => ( + <FormItem> + <FormLabel> + 계약기간 <span className="text-red-500">*</span> + </FormLabel> <FormControl> - <SelectTrigger> - <SelectValue placeholder="입찰유형 선택" /> - </SelectTrigger> + <Input + placeholder="예: 계약일로부터 60일" + {...field} + /> </FormControl> - <SelectContent> - {Object.entries(biddingTypeLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}> - {label} - </SelectItem> - ))} - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <div className="grid grid-cols-2 gap-6"> - {/* 낙찰수 */} + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle>가격 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + {/* 통화 */} <FormField control={form.control} - name="awardCount" + name="currency" render={({ field }) => ( <FormItem> <FormLabel> - 낙찰수 <span className="text-red-500">*</span> + 통화 <span className="text-red-500">*</span> </FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> - <SelectValue placeholder="낙찰수 선택" /> + <SelectValue placeholder="통화 선택" /> </SelectTrigger> </FormControl> <SelectContent> - {Object.entries(awardCountLabels).map(([value, label]) => ( - <SelectItem key={value} value={value}> - {label} - </SelectItem> - ))} + <SelectItem value="KRW">KRW (원)</SelectItem> + <SelectItem value="USD">USD (달러)</SelectItem> + <SelectItem value="EUR">EUR (유로)</SelectItem> + <SelectItem value="JPY">JPY (엔)</SelectItem> </SelectContent> </Select> <FormMessage /> @@ -779,684 +873,688 @@ export function CreateBiddingDialog() { )} /> - {/* 계약기간 */} - <FormField - control={form.control} - name="contractPeriod" - render={({ field }) => ( - <FormItem> - <FormLabel> - 계약기간 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> - <Input - placeholder="예: 계약일로부터 60일" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </CardContent> - </Card> - - <Card> - <CardHeader> - <CardTitle>가격 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - {/* 통화 */} - <FormField - control={form.control} - name="currency" - render={({ field }) => ( - <FormItem> - <FormLabel> - 통화 <span className="text-red-500">*</span> - </FormLabel> - <Select onValueChange={field.onChange} defaultValue={field.value}> - <FormControl> - <SelectTrigger> - <SelectValue placeholder="통화 선택" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectItem value="KRW">KRW (원)</SelectItem> - <SelectItem value="USD">USD (달러)</SelectItem> - <SelectItem value="EUR">EUR (유로)</SelectItem> - <SelectItem value="JPY">JPY (엔)</SelectItem> - </SelectContent> - </Select> - <FormMessage /> - </FormItem> - )} - /> - - <div className="grid grid-cols-3 gap-6"> - {/* 예산 */} - <FormField - control={form.control} - name="budget" - render={({ field }) => ( - <FormItem> - <FormLabel>예산</FormLabel> - <FormControl> - <Input - type="number" - step="0.01" - placeholder="0" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 내정가 */} + <div className="grid grid-cols-3 gap-6"> + {/* 예산 */} + <FormField + control={form.control} + name="budget" + render={({ field }) => ( + <FormItem> + <FormLabel>예산</FormLabel> + <FormControl> + <Input + type="number" + step="0.01" + placeholder="0" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 내정가 */} + <FormField + control={form.control} + name="targetPrice" + render={({ field }) => ( + <FormItem> + <FormLabel>내정가</FormLabel> + <FormControl> + <Input + type="number" + step="0.01" + placeholder="0" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 최종입찰가 */} + <FormField + control={form.control} + name="finalBidPrice" + render={({ field }) => ( + <FormItem> + <FormLabel>최종입찰가</FormLabel> + <FormControl> + <Input + type="number" + step="0.01" + placeholder="0" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + </TabsContent> + + {/* 일정 & 회의 탭 */} + <TabsContent value="schedule" className="mt-0 space-y-6"> + <Card> + <CardHeader> + <CardTitle>일정 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <div className="grid grid-cols-2 gap-6"> + {/* 제출시작일시 */} + <FormField + control={form.control} + name="submissionStartDate" + render={({ field }) => ( + <FormItem> + <FormLabel> + 제출시작일시 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + type="datetime-local" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 제출마감일시 */} + <FormField + control={form.control} + name="submissionEndDate" + render={({ field }) => ( + <FormItem> + <FormLabel> + 제출마감일시 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + type="datetime-local" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + {/* 사양설명회 */} + <Card> + <CardHeader> + <CardTitle>사양설명회</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> <FormField control={form.control} - name="targetPrice" + name="hasSpecificationMeeting" render={({ field }) => ( - <FormItem> - <FormLabel>내정가</FormLabel> + <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> - <Input - type="number" - step="0.01" - placeholder="0" - {...field} + <Switch + checked={field.value} + onCheckedChange={field.onChange} /> </FormControl> - <FormMessage /> </FormItem> )} /> - {/* 최종입찰가 */} - <FormField - control={form.control} - name="finalBidPrice" - render={({ field }) => ( - <FormItem> - <FormLabel>최종입찰가</FormLabel> - <FormControl> - <Input - type="number" - step="0.01" - placeholder="0" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </CardContent> - </Card> - </TabsContent> - - {/* 일정 & 회의 탭 */} - <TabsContent value="schedule" className="mt-0 space-y-6"> - <Card> - <CardHeader> - <CardTitle>일정 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <div className="grid grid-cols-2 gap-6"> - {/* 제출시작일시 */} - <FormField - control={form.control} - name="submissionStartDate" - render={({ field }) => ( - <FormItem> - <FormLabel> - 제출시작일시 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> + {/* 사양설명회 정보 (조건부 표시) */} + {form.watch("hasSpecificationMeeting") && ( + <div className="space-y-6 p-4 border rounded-lg bg-muted/50"> + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-sm font-medium"> + 회의일시 <span className="text-red-500">*</span> + </label> <Input type="datetime-local" - {...field} + value={specMeetingInfo.meetingDate} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingDate: e.target.value }))} + className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 제출마감일시 */} - <FormField - control={form.control} - name="submissionEndDate" - render={({ field }) => ( - <FormItem> - <FormLabel> - 제출마감일시 <span className="text-red-500">*</span> - </FormLabel> - <FormControl> + {!specMeetingInfo.meetingDate && ( + <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p> + )} + </div> + <div> + <label className="text-sm font-medium">회의시간</label> <Input - type="datetime-local" - {...field} + placeholder="예: 14:00 ~ 16:00" + value={specMeetingInfo.meetingTime} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingTime: e.target.value }))} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - </CardContent> - </Card> - - {/* 사양설명회 */} - <Card> - <CardHeader> - <CardTitle>사양설명회</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <FormField - control={form.control} - name="hasSpecificationMeeting" - 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> </div> - <FormControl> - <Switch - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - </FormItem> - )} - /> - {/* 사양설명회 정보 (조건부 표시) */} - {form.watch("hasSpecificationMeeting") && ( - <div className="space-y-6 p-4 border rounded-lg bg-muted/50"> - <div className="grid grid-cols-2 gap-4"> <div> <label className="text-sm font-medium"> - 회의일시 <span className="text-red-500">*</span> + 장소 <span className="text-red-500">*</span> </label> <Input - type="datetime-local" - value={specMeetingInfo.meetingDate} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingDate: e.target.value }))} - className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''} + placeholder="회의 장소" + value={specMeetingInfo.location} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, location: e.target.value }))} + className={!specMeetingInfo.location ? 'border-red-200' : ''} /> - {!specMeetingInfo.meetingDate && ( - <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p> + {!specMeetingInfo.location && ( + <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p> )} </div> - <div> - <label className="text-sm font-medium">회의시간</label> - <Input - placeholder="예: 14:00 ~ 16:00" - value={specMeetingInfo.meetingTime} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingTime: e.target.value }))} - /> - </div> - </div> - - <div> - <label className="text-sm font-medium"> - 장소 <span className="text-red-500">*</span> - </label> - <Input - placeholder="회의 장소" - value={specMeetingInfo.location} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, location: e.target.value }))} - className={!specMeetingInfo.location ? 'border-red-200' : ''} - /> - {!specMeetingInfo.location && ( - <p className="text-sm text-red-500 mt-1">회의 장소는 필수입니다</p> - )} - </div> - <div> - <label className="text-sm font-medium">주소</label> - <Textarea - placeholder="상세 주소" - value={specMeetingInfo.address} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, address: e.target.value }))} - /> - </div> - - <div className="grid grid-cols-3 gap-4"> <div> - <label className="text-sm font-medium"> - 담당자 <span className="text-red-500">*</span> - </label> - <Input - placeholder="담당자명" - value={specMeetingInfo.contactPerson} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPerson: e.target.value }))} - className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''} - /> - {!specMeetingInfo.contactPerson && ( - <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p> - )} - </div> - <div> - <label className="text-sm font-medium">연락처</label> - <Input - placeholder="전화번호" - value={specMeetingInfo.contactPhone} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPhone: e.target.value }))} + <label className="text-sm font-medium">주소</label> + <Textarea + placeholder="상세 주소" + value={specMeetingInfo.address} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, address: e.target.value }))} /> </div> - <div> - <label className="text-sm font-medium">이메일</label> - <Input - type="email" - placeholder="이메일" - value={specMeetingInfo.contactEmail} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactEmail: e.target.value }))} - /> + + <div className="grid grid-cols-3 gap-4"> + <div> + <label className="text-sm font-medium"> + 담당자 <span className="text-red-500">*</span> + </label> + <Input + placeholder="담당자명" + value={specMeetingInfo.contactPerson} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPerson: e.target.value }))} + className={!specMeetingInfo.contactPerson ? 'border-red-200' : ''} + /> + {!specMeetingInfo.contactPerson && ( + <p className="text-sm text-red-500 mt-1">담당자는 필수입니다</p> + )} + </div> + <div> + <label className="text-sm font-medium">연락처</label> + <Input + placeholder="전화번호" + value={specMeetingInfo.contactPhone} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactPhone: e.target.value }))} + /> + </div> + <div> + <label className="text-sm font-medium">이메일</label> + <Input + type="email" + placeholder="이메일" + value={specMeetingInfo.contactEmail} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, contactEmail: e.target.value }))} + /> + </div> </div> - </div> - <div className="grid grid-cols-2 gap-4"> - <div> - <label className="text-sm font-medium">회의 안건</label> - <Textarea - placeholder="회의 안건" - value={specMeetingInfo.agenda} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, agenda: e.target.value }))} - /> + <div className="grid grid-cols-2 gap-4"> + <div> + <label className="text-sm font-medium">회의 안건</label> + <Textarea + placeholder="회의 안건" + value={specMeetingInfo.agenda} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, agenda: e.target.value }))} + /> + </div> + <div> + <label className="text-sm font-medium">준비물 & 특이사항</label> + <Textarea + placeholder="준비물 및 특이사항" + value={specMeetingInfo.materials} + onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, materials: e.target.value }))} + /> + </div> </div> - <div> - <label className="text-sm font-medium">준비물 & 특이사항</label> - <Textarea - placeholder="준비물 및 특이사항" - value={specMeetingInfo.materials} - onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, materials: e.target.value }))} + + <div className="flex items-center space-x-2"> + <Switch + id="required-meeting" + checked={specMeetingInfo.isRequired} + onCheckedChange={(checked) => setSpecMeetingInfo(prev => ({ ...prev, isRequired: checked }))} /> + <label htmlFor="required-meeting" className="text-sm font-medium"> + 필수 참석 + </label> </div> - </div> - <div className="flex items-center space-x-2"> - <Switch - id="required-meeting" - checked={specMeetingInfo.isRequired} - onCheckedChange={(checked) => setSpecMeetingInfo(prev => ({ ...prev, isRequired: checked }))} - /> - <label htmlFor="required-meeting" className="text-sm font-medium"> - 필수 참석 - </label> - </div> - - {/* 사양설명회 첨부 파일 */} - <div className="space-y-4"> - <label className="text-sm font-medium">사양설명회 관련 첨부 파일</label> - <Dropzone - onDrop={addMeetingFiles} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - 'image/*': ['.png', '.jpg', '.jpeg'], - }} - multiple - className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors" - > - <DropzoneZone> - <DropzoneUploadIcon /> - <DropzoneTitle>사양설명회 관련 문서 업로드</DropzoneTitle> - <DropzoneDescription> - 안내문, 도면, 자료 등을 업로드하세요 (PDF, Word, Excel, 이미지 파일 지원) - </DropzoneDescription> - </DropzoneZone> - <DropzoneInput /> - </Dropzone> - - {specMeetingInfo.meetingFiles.length > 0 && ( - <FileList className="mt-4"> - <FileListHeader> - <span>업로드된 파일 ({specMeetingInfo.meetingFiles.length})</span> - </FileListHeader> - {specMeetingInfo.meetingFiles.map((file, fileIndex) => ( - <FileListItem key={fileIndex}> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.name}</FileListName> - <FileListSize>{file.size}</FileListSize> - </FileListInfo> - <FileListAction> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => removeMeetingFile(fileIndex)} - > - 삭제 - </Button> - </FileListAction> - </FileListItem> - ))} - </FileList> - )} + {/* 사양설명회 첨부 파일 */} + <div className="space-y-4"> + <label className="text-sm font-medium">사양설명회 관련 첨부 파일</label> + <Dropzone + onDrop={addMeetingFiles} + accept={{ + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'image/*': ['.png', '.jpg', '.jpeg'], + }} + multiple + className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors" + > + <DropzoneZone> + <DropzoneUploadIcon /> + <DropzoneTitle>사양설명회 관련 문서 업로드</DropzoneTitle> + <DropzoneDescription> + 안내문, 도면, 자료 등을 업로드하세요 (PDF, Word, Excel, 이미지 파일 지원) + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> + </Dropzone> + + {specMeetingInfo.meetingFiles.length > 0 && ( + <FileList className="mt-4"> + <FileListHeader> + <span>업로드된 파일 ({specMeetingInfo.meetingFiles.length})</span> + </FileListHeader> + {specMeetingInfo.meetingFiles.map((file, fileIndex) => ( + <FileListItem key={fileIndex}> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListSize>{file.size}</FileListSize> + </FileListInfo> + <FileListAction> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => removeMeetingFile(fileIndex)} + > + 삭제 + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + )} + </div> </div> + )} + </CardContent> + </Card> + </TabsContent> + + {/* 세부내역 탭 */} + <TabsContent value="details" className="mt-0 space-y-6"> + <Card> + <CardHeader className="flex flex-row items-center justify-between"> + <div> + <CardTitle>세부내역 관리</CardTitle> + <p className="text-sm text-muted-foreground mt-1"> + PR 아이템 또는 수기 아이템을 추가하여 입찰 세부내역을 관리하세요 + </p> </div> - )} - </CardContent> - </Card> - </TabsContent> - - {/* 세부내역 탭 */} - <TabsContent value="details" className="mt-0 space-y-6"> - <Card> - <CardHeader className="flex flex-row items-center justify-between"> - <div> - <CardTitle>세부내역 관리</CardTitle> - <p className="text-sm text-muted-foreground mt-1"> - PR 아이템 또는 수기 아이템을 추가하여 입찰 세부내역을 관리하세요 - </p> - </div> - <Button - type="button" - variant="outline" - onClick={addPRItem} - className="flex items-center gap-2" - > - <Plus className="h-4 w-4" /> - 아이템 추가 - </Button> - </CardHeader> - <CardContent className="space-y-6"> - {/* 아이템 테이블 */} - {prItems.length > 0 ? ( - <div className="space-y-4"> - <div className="border rounded-lg"> - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[60px]">대표</TableHead> - <TableHead className="w-[120px]">PR 번호</TableHead> - <TableHead className="w-[120px]">품목코드</TableHead> - <TableHead>품목정보</TableHead> - <TableHead className="w-[80px]">수량</TableHead> - <TableHead className="w-[80px]">단위</TableHead> - <TableHead className="w-[140px]">납품요청일</TableHead> - <TableHead className="w-[80px]">스펙파일</TableHead> - <TableHead className="w-[80px]">액션</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {prItems.map((item, index) => ( - <TableRow key={item.id}> - <TableCell> - <div className="flex justify-center"> - <Checkbox - checked={item.isRepresentative} - onCheckedChange={() => setRepresentativeItem(item.id)} + <Button + type="button" + variant="outline" + onClick={addPRItem} + className="flex items-center gap-2" + > + <Plus className="h-4 w-4" /> + 아이템 추가 + </Button> + </CardHeader> + <CardContent className="space-y-6"> + {/* 아이템 테이블 */} + {prItems.length > 0 ? ( + <div className="space-y-4"> + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[60px]">대표</TableHead> + <TableHead className="w-[120px]">PR 번호</TableHead> + <TableHead className="w-[120px]">품목코드</TableHead> + <TableHead>품목정보</TableHead> + <TableHead className="w-[80px]">수량</TableHead> + <TableHead className="w-[80px]">단위</TableHead> + <TableHead className="w-[140px]">납품요청일</TableHead> + <TableHead className="w-[80px]">스펙파일</TableHead> + <TableHead className="w-[80px]">액션</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {prItems.map((item, index) => ( + <TableRow key={item.id}> + <TableCell> + <div className="flex justify-center"> + <Checkbox + checked={item.isRepresentative} + onCheckedChange={() => setRepresentativeItem(item.id)} + /> + </div> + </TableCell> + <TableCell> + <Input + placeholder="PR 번호" + value={item.prNumber} + onChange={(e) => updatePRItem(item.id, { prNumber: e.target.value })} + className="h-8" /> - </div> - </TableCell> - <TableCell> - <Input - placeholder="PR 번호" - value={item.prNumber} - onChange={(e) => updatePRItem(item.id, { prNumber: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <Input - placeholder={`ITEM-${index + 1}`} - value={item.itemCode} - onChange={(e) => updatePRItem(item.id, { itemCode: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <Input - placeholder="품목정보" - value={item.itemInfo} - onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <Input - type="number" - placeholder="수량" - value={item.quantity} - onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <Select - value={item.quantityUnit} - onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })} - > - <SelectTrigger className="h-8"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="EA">EA</SelectItem> - <SelectItem value="SET">SET</SelectItem> - <SelectItem value="LOT">LOT</SelectItem> - <SelectItem value="M">M</SelectItem> - <SelectItem value="M2">M²</SelectItem> - <SelectItem value="M3">M³</SelectItem> - </SelectContent> - </Select> - </TableCell> - <TableCell> - <Input - type="date" - value={item.requestedDeliveryDate} - onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} - className="h-8" - /> - </TableCell> - <TableCell> - <div className="flex items-center gap-2"> + </TableCell> + <TableCell> + <Input + placeholder={`ITEM-${index + 1}`} + value={item.itemCode} + onChange={(e) => updatePRItem(item.id, { itemCode: e.target.value })} + className="h-8" + /> + </TableCell> + <TableCell> + <Input + placeholder="품목정보" + value={item.itemInfo} + onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })} + className="h-8" + /> + </TableCell> + <TableCell> + <Input + type="number" + placeholder="수량" + value={item.quantity} + onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })} + className="h-8" + /> + </TableCell> + <TableCell> + <Select + value={item.quantityUnit} + onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })} + > + <SelectTrigger className="h-8"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="EA">EA</SelectItem> + <SelectItem value="SET">SET</SelectItem> + <SelectItem value="LOT">LOT</SelectItem> + <SelectItem value="M">M</SelectItem> + <SelectItem value="M2">M²</SelectItem> + <SelectItem value="M3">M³</SelectItem> + </SelectContent> + </Select> + </TableCell> + <TableCell> + <Input + type="date" + value={item.requestedDeliveryDate} + onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })} + className="h-8" + /> + </TableCell> + <TableCell> + <div className="flex items-center gap-2"> + <Button + type="button" + variant={selectedItemForFile === item.id ? "default" : "outline"} + size="sm" + onClick={() => setSelectedItemForFile(selectedItemForFile === item.id ? null : item.id)} + className="h-8 w-8 p-0" + > + <Paperclip className="h-4 w-4" /> + </Button> + <span className="text-sm">{item.specFiles.length}</span> + </div> + </TableCell> + <TableCell> <Button type="button" - variant={selectedItemForFile === item.id ? "default" : "outline"} + variant="outline" size="sm" - onClick={() => setSelectedItemForFile(selectedItemForFile === item.id ? null : item.id)} + onClick={() => removePRItem(item.id)} className="h-8 w-8 p-0" > - <Paperclip className="h-4 w-4" /> + <Trash2 className="h-4 w-4" /> </Button> - <span className="text-sm">{item.specFiles.length}</span> - </div> - </TableCell> - <TableCell> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => removePRItem(item.id)} - className="h-8 w-8 p-0" - > - <Trash2 className="h-4 w-4" /> - </Button> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - - {/* 대표 아이템 정보 표시 */} - {representativePrNumber && ( - <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"> - <CheckCircle2 className="h-4 w-4 text-blue-600" /> - <span className="text-sm text-blue-800"> - 대표 PR 번호: <strong>{representativePrNumber}</strong> - </span> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> </div> - )} - {/* 선택된 아이템의 파일 업로드 */} - {selectedItemForFile && ( - <div className="space-y-4 p-4 border rounded-lg bg-muted/50"> - {(() => { - const selectedItem = prItems.find(item => item.id === selectedItemForFile) - return ( - <> - <div className="flex items-center justify-between"> - <h6 className="font-medium text-sm"> - {selectedItem?.itemInfo || selectedItem?.itemCode || "선택된 아이템"}의 스펙 파일 - </h6> - <Button - type="button" - variant="ghost" - size="sm" - onClick={() => setSelectedItemForFile(null)} + {/* 대표 아이템 정보 표시 */} + {representativePrNumber && ( + <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"> + <CheckCircle2 className="h-4 w-4 text-blue-600" /> + <span className="text-sm text-blue-800"> + 대표 PR 번호: <strong>{representativePrNumber}</strong> + </span> + </div> + )} + + {/* 선택된 아이템의 파일 업로드 */} + {selectedItemForFile && ( + <div className="space-y-4 p-4 border rounded-lg bg-muted/50"> + {(() => { + const selectedItem = prItems.find(item => item.id === selectedItemForFile) + return ( + <> + <div className="flex items-center justify-between"> + <h6 className="font-medium text-sm"> + {selectedItem?.itemInfo || selectedItem?.itemCode || "선택된 아이템"}의 스펙 파일 + </h6> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => setSelectedItemForFile(null)} + > + 닫기 + </Button> + </div> + + <Dropzone + onDrop={(files) => addSpecFiles(selectedItemForFile, files)} + accept={{ + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + }} + multiple + className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-gray-400 transition-colors" > - 닫기 - </Button> - </div> - - <Dropzone - onDrop={(files) => addSpecFiles(selectedItemForFile, files)} - accept={{ - 'application/pdf': ['.pdf'], - 'application/msword': ['.doc'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], - 'application/vnd.ms-excel': ['.xls'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], - }} - multiple - className="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-gray-400 transition-colors" - > - <DropzoneZone> - <DropzoneUploadIcon /> - <DropzoneTitle>스펙 문서 업로드</DropzoneTitle> - <DropzoneDescription> - PDF, Word, Excel 파일을 드래그하거나 클릭하여 선택 - </DropzoneDescription> - </DropzoneZone> - <DropzoneInput /> - </Dropzone> - - {selectedItem && selectedItem.specFiles.length > 0 && ( - <FileList className="mt-4"> - <FileListHeader> - <span>업로드된 파일 ({selectedItem.specFiles.length})</span> - </FileListHeader> - {selectedItem.specFiles.map((file, fileIndex) => ( - <FileListItem key={fileIndex}> - <FileListIcon /> - <FileListInfo> - <FileListName>{file.name}</FileListName> - <FileListSize>{file.size}</FileListSize> - </FileListInfo> - <FileListAction> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => removeSpecFile(selectedItemForFile, fileIndex)} - > - 삭제 - </Button> - </FileListAction> - </FileListItem> - ))} - </FileList> - )} - </> - ) - })()} - </div> - )} - </div> - ) : ( - <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg"> - <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" /> - <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p> - <p className="text-sm text-gray-400 mb-4"> - PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요 - </p> - <Button - type="button" - variant="outline" - onClick={addPRItem} - className="flex items-center gap-2" - > - <Plus className="h-4 w-4" /> - 첫 번째 아이템 추가 - </Button> - </div> - )} - </CardContent> - </Card> - </TabsContent> - - {/* 담당자 & 기타 탭 */} - <TabsContent value="manager" className="mt-0 space-y-6"> - {/* 담당자 정보 */} - <Card> - <CardHeader> - <CardTitle>담당자 정보</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <FormField - control={form.control} - name="managerName" - render={({ field }) => ( - <FormItem> - <FormLabel>담당자명</FormLabel> - <FormControl> - <Input - placeholder="담당자명" - {...field} - /> - </FormControl> - <FormDescription> - 현재 로그인한 사용자 정보로 자동 설정됩니다. - </FormDescription> - <FormMessage /> - </FormItem> + <DropzoneZone> + <DropzoneUploadIcon /> + <DropzoneTitle>스펙 문서 업로드</DropzoneTitle> + <DropzoneDescription> + PDF, Word, Excel 파일을 드래그하거나 클릭하여 선택 + </DropzoneDescription> + </DropzoneZone> + <DropzoneInput /> + </Dropzone> + + {selectedItem && selectedItem.specFiles.length > 0 && ( + <FileList className="mt-4"> + <FileListHeader> + <span>업로드된 파일 ({selectedItem.specFiles.length})</span> + </FileListHeader> + {selectedItem.specFiles.map((file, fileIndex) => ( + <FileListItem + key={fileIndex} + className="flex items-center justify-between p-3 border rounded-lg mb-2" + > + <div className="flex items-center gap-3 flex-1"> + <FileListIcon className="flex-shrink-0" /> + <FileListInfo className="flex items-center gap-3 flex-1"> + <FileListName className="font-medium text-gray-700"> + {file.name} + </FileListName> + <FileListSize className="text-sm text-gray-500"> + {file.size} + </FileListSize> + </FileListInfo> + </div> + <FileListAction className="flex-shrink-0"> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => removeSpecFile(selectedItemForFile, fileIndex)} + > + 삭제 + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + )} + </> + ) + })()} + </div> + )} + </div> + ) : ( + <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg"> + <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" /> + <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p> + <p className="text-sm text-gray-400 mb-4"> + PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요 + </p> + <Button + type="button" + variant="outline" + onClick={addPRItem} + className="flex items-center gap-2 mx-auto" + > + <Plus className="h-4 w-4" /> + 첫 번째 아이템 추가 + </Button> + </div> )} - /> - - <div className="grid grid-cols-2 gap-6"> + </CardContent> + </Card> + </TabsContent> + + {/* 담당자 & 기타 탭 */} + <TabsContent value="manager" className="mt-0 space-y-6"> + {/* 담당자 정보 */} + <Card> + <CardHeader> + <CardTitle>담당자 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> <FormField control={form.control} - name="managerEmail" + name="managerName" render={({ field }) => ( <FormItem> - <FormLabel>담당자 이메일</FormLabel> + <FormLabel>담당자명</FormLabel> <FormControl> <Input - type="email" - placeholder="email@example.com" + placeholder="담당자명" {...field} /> </FormControl> + <FormDescription> + 현재 로그인한 사용자 정보로 자동 설정됩니다. + </FormDescription> <FormMessage /> </FormItem> )} /> + <div className="grid grid-cols-2 gap-6"> + <FormField + control={form.control} + name="managerEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 이메일</FormLabel> + <FormControl> + <Input + type="email" + placeholder="email@example.com" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="managerPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 전화번호</FormLabel> + <FormControl> + <Input + placeholder="010-1234-5678" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </CardContent> + </Card> + + {/* 기타 설정 */} + <Card> + <CardHeader> + <CardTitle>기타 설정</CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + <FormField + control={form.control} + name="isPublic" + 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="managerPhone" + name="remarks" render={({ field }) => ( <FormItem> - <FormLabel>담당자 전화번호</FormLabel> + <FormLabel>비고</FormLabel> <FormControl> - <Input - placeholder="010-1234-5678" + <Textarea + placeholder="추가 메모나 특이사항을 입력하세요" + rows={4} {...field} /> </FormControl> @@ -1464,211 +1562,183 @@ export function CreateBiddingDialog() { </FormItem> )} /> - </div> - </CardContent> - </Card> - - {/* 기타 설정 */} - <Card> - <CardHeader> - <CardTitle>기타 설정</CardTitle> - </CardHeader> - <CardContent className="space-y-6"> - <FormField - control={form.control} - name="isPublic" - 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> - <FormLabel>비고</FormLabel> - <FormControl> - <Textarea - placeholder="추가 메모나 특이사항을 입력하세요" - rows={4} - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </CardContent> - </Card> - - {/* 입찰 생성 요약 */} - <Card> - <CardHeader> - <CardTitle>입찰 생성 요약</CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-2 gap-4 text-sm"> - <div> - <span className="font-medium">프로젝트:</span> - <p className="text-muted-foreground"> - {form.watch("projectName") || "선택되지 않음"} - </p> - </div> - <div> - <span className="font-medium">입찰명:</span> - <p className="text-muted-foreground"> - {form.watch("title") || "입력되지 않음"} - </p> - </div> - <div> - <span className="font-medium">계약구분:</span> - <p className="text-muted-foreground"> - {contractTypeLabels[form.watch("contractType") as keyof typeof contractTypeLabels] || "선택되지 않음"} - </p> - </div> - <div> - <span className="font-medium">입찰유형:</span> - <p className="text-muted-foreground"> - {biddingTypeLabels[form.watch("biddingType") as keyof typeof biddingTypeLabels] || "선택되지 않음"} - </p> - </div> - <div> - <span className="font-medium">사양설명회:</span> - <p className="text-muted-foreground"> - {form.watch("hasSpecificationMeeting") ? "실시함" : "실시하지 않음"} - </p> - </div> - <div> - <span className="font-medium">대표 PR 번호:</span> - <p className="text-muted-foreground"> - {representativePrNumber || "설정되지 않음"} - </p> - </div> - <div> - <span className="font-medium">세부 아이템:</span> - <p className="text-muted-foreground"> - {prItems.length}개 아이템 - </p> - </div> - <div> - <span className="font-medium">사양설명회 파일:</span> - <p className="text-muted-foreground"> - {specMeetingInfo.meetingFiles.length}개 파일 - </p> + </CardContent> + </Card> + + {/* 입찰 생성 요약 */} + <Card> + <CardHeader> + <CardTitle>입찰 생성 요약</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">프로젝트:</span> + <p className="text-muted-foreground"> + {form.watch("projectName") || "선택되지 않음"} + </p> + </div> + <div> + <span className="font-medium">입찰명:</span> + <p className="text-muted-foreground"> + {form.watch("title") || "입력되지 않음"} + </p> + </div> + <div> + <span className="font-medium">계약구분:</span> + <p className="text-muted-foreground"> + {contractTypeLabels[form.watch("contractType") as keyof typeof contractTypeLabels] || "선택되지 않음"} + </p> + </div> + <div> + <span className="font-medium">입찰유형:</span> + <p className="text-muted-foreground"> + {biddingTypeLabels[form.watch("biddingType") as keyof typeof biddingTypeLabels] || "선택되지 않음"} + </p> + </div> + <div> + <span className="font-medium">사양설명회:</span> + <p className="text-muted-foreground"> + {form.watch("hasSpecificationMeeting") ? "실시함" : "실시하지 않음"} + </p> + </div> + <div> + <span className="font-medium">대표 PR 번호:</span> + <p className="text-muted-foreground"> + {representativePrNumber || "설정되지 않음"} + </p> + </div> + <div> + <span className="font-medium">세부 아이템:</span> + <p className="text-muted-foreground"> + {prItems.length}개 아이템 + </p> + </div> + <div> + <span className="font-medium">사양설명회 파일:</span> + <p className="text-muted-foreground"> + {specMeetingInfo.meetingFiles.length}개 파일 + </p> + </div> </div> - </div> - </CardContent> - </Card> - </TabsContent> + </CardContent> + </Card> + </TabsContent> - </div> - </Tabs> - </div> - - {/* 고정 버튼 영역 */} - <div className="flex-shrink-0 border-t bg-background p-6"> - <div className="flex justify-between items-center"> - <div className="text-sm text-muted-foreground"> - {activeTab === "basic" && ( - <span> - 기본 정보를 입력하세요 - {!tabValidation.basic.isValid && ( - <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> - )} - </span> - )} - {activeTab === "contract" && ( - <span> - 계약 및 가격 정보를 입력하세요 - {!tabValidation.contract.isValid && ( - <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> - )} - </span> - )} - {activeTab === "schedule" && ( - <span> - 일정 및 사양설명회 정보를 입력하세요 - {!tabValidation.schedule.isValid && ( - <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> - )} - </span> - )} - {activeTab === "details" && "세부내역 아이템을 관리하세요 (선택사항)"} - {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"} - </div> + </div> + </Tabs> + </div> + + {/* 고정 버튼 영역 */} + <div className="flex-shrink-0 border-t bg-background p-6"> + <div className="flex justify-between items-center"> + <div className="text-sm text-muted-foreground"> + {activeTab === "basic" && ( + <span> + 기본 정보를 입력하세요 + {!tabValidation.basic.isValid && ( + <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> + )} + </span> + )} + {activeTab === "contract" && ( + <span> + 계약 및 가격 정보를 입력하세요 + {!tabValidation.contract.isValid && ( + <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> + )} + </span> + )} + {activeTab === "schedule" && ( + <span> + 일정 및 사양설명회 정보를 입력하세요 + {!tabValidation.schedule.isValid && ( + <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span> + )} + </span> + )} + {activeTab === "details" && "세부내역 아이템을 관리하세요 (선택사항)"} + {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"} + </div> - <div className="flex gap-3"> - <Button - type="button" - variant="outline" - onClick={() => { - resetAllStates() - setOpen(false) - }} - disabled={isSubmitting} - > - 취소 - </Button> - - {/* 이전 버튼 (첫 번째 탭이 아닐 때) */} - {!isFirstTab && ( + <div className="flex gap-3"> <Button type="button" variant="outline" - onClick={goToPreviousTab} + onClick={() => { + resetAllStates() + setOpen(false) + }} disabled={isSubmitting} - className="flex items-center gap-2" > - <ChevronLeft className="h-4 w-4" /> - 이전 + 취소 </Button> - )} - {/* 다음/생성 버튼 */} - {isLastTab ? ( - // 마지막 탭: 입찰 생성 버튼 (submit) - <Button - type="submit" - disabled={isSubmitting} - className="flex items-center gap-2" - > - {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - 입찰 생성 - </Button> - ) : ( - // 이전 탭들: 다음 버튼 (일반 버튼) - <Button - type="button" - onClick={handleNextClick} - disabled={isSubmitting} - className="flex items-center gap-2" - > - 다음 - <ChevronRight className="h-4 w-4" /> - </Button> - )} + {/* 이전 버튼 (첫 번째 탭이 아닐 때) */} + {!isFirstTab && ( + <Button + type="button" + variant="outline" + onClick={goToPreviousTab} + disabled={isSubmitting} + className="flex items-center gap-2" + > + <ChevronLeft className="h-4 w-4" /> + 이전 + </Button> + )} + + {/* 다음/생성 버튼 */} + {isLastTab ? ( + // 마지막 탭: 입찰 생성 버튼 (type="button"으로 변경) + <Button + type="button" + onClick={handleCreateBidding} + disabled={isSubmitting} + className="flex items-center gap-2" + > + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + 입찰 생성 + </Button> + ) : ( + // 이전 탭들: 다음 버튼 + <Button + type="button" + onClick={handleNextClick} + disabled={isSubmitting} + className="flex items-center gap-2" + > + 다음 + <ChevronRight className="h-4 w-4" /> + </Button> + )} + </div> </div> </div> - </div> - </form> - </Form> - </DialogContent> - </Dialog> + </form> + </Form> + </DialogContent> + </Dialog> + + <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>입찰이 성공적으로 생성되었습니다</AlertDialogTitle> + <AlertDialogDescription> + 생성된 입찰의 상세페이지로 이동하시겠습니까? + 아니면 현재 페이지에 남아있으시겠습니까? + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={handleStayOnPage}> + 현재 페이지에 남기 + </AlertDialogCancel> + <AlertDialogAction onClick={handleNavigateToDetail}> + 상세페이지로 이동 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> ) }
\ No newline at end of file diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 91fea75e..5d384476 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -8,7 +8,8 @@ import { projects, biddingDocuments, prItemsForBidding, - specificationMeetings + specificationMeetings, + prDocuments } from '@/db/schema' import { eq, @@ -21,7 +22,7 @@ import { ilike, gte, lte, - SQL + SQL, like } from 'drizzle-orm' import { revalidatePath } from 'next/cache' import { BiddingListItem } from '@/db/schema' @@ -91,6 +92,9 @@ export async function getBiddings(input: GetBiddingsSchema) { try { const offset = (input.page - 1) * input.perPage + console.log(input.filters) + console.log(input.sort) + // ✅ 1) 고급 필터 조건 let advancedWhere: SQL<unknown> | undefined = undefined if (input.filters && input.filters.length > 0) { @@ -378,7 +382,7 @@ export interface UpdateBiddingInput extends UpdateBiddingSchema { } // 자동 입찰번호 생성 -async function generateBiddingNumber(biddingType: string): Promise<string> { +async function generateBiddingNumber(biddingType: string, tx?: any, maxRetries: number = 5): Promise<string> { const year = new Date().getFullYear() const typePrefix = { 'equipment': 'EQ', @@ -392,22 +396,44 @@ async function generateBiddingNumber(biddingType: string): Promise<string> { 'sale': 'SL' }[biddingType] || 'GN' - // 해당 연도의 마지막 번호 조회 - const lastBidding = await db - .select({ biddingNumber: biddings.biddingNumber }) - .from(biddings) - .where(eq(biddings.biddingNumber, `${year}${typePrefix}%`)) - .orderBy(biddings.biddingNumber) - .limit(1) - - let sequence = 1 - if (lastBidding.length > 0) { - const lastNumber = lastBidding[0].biddingNumber - const lastSequence = parseInt(lastNumber.slice(-4)) - sequence = lastSequence + 1 + const dbInstance = tx || db + const prefix = `${year}${typePrefix}` + + for (let attempt = 0; attempt < maxRetries; attempt++) { + // 현재 최대 시퀀스 번호 조회 + const result = await dbInstance + .select({ + maxNumber: sql<string>`MAX(${biddings.biddingNumber})` + }) + .from(biddings) + .where(like(biddings.biddingNumber, `${prefix}%`)) + + let sequence = 1 + if (result[0]?.maxNumber) { + const lastSequence = parseInt(result[0].maxNumber.slice(-4)) + if (!isNaN(lastSequence)) { + sequence = lastSequence + 1 + } + } + + const biddingNumber = `${prefix}${sequence.toString().padStart(4, '0')}` + + // 중복 확인 + const existing = await dbInstance + .select({ id: biddings.id }) + .from(biddings) + .where(eq(biddings.biddingNumber, biddingNumber)) + .limit(1) + + if (existing.length === 0) { + return biddingNumber + } + + // 중복이 발견되면 잠시 대기 후 재시도 + await new Promise(resolve => setTimeout(resolve, 10 + Math.random() * 20)) } - return `${year}${typePrefix}${sequence.toString().padStart(4, '0')}` + throw new Error(`Failed to generate unique bidding number after ${maxRetries} attempts`) } // 입찰 생성 @@ -419,7 +445,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { // 프로젝트 정보 조회 let projectName = input.projectName - if (input.projectId && !projectName) { + if (input.projectId) { const project = await tx .select({ code: projects.code, name: projects.name }) .from(projects) @@ -549,8 +575,8 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { originalFileName: saveResult.originalName!, fileSize: saveResult.fileSize!, mimeType: file.type, - filePath: saveResult.filePath!, - publicPath: saveResult.publicPath, + filePath: saveResult.publicPath!, + // publicPath: saveResult.publicPath, title: `사양설명회 - ${file.name}`, isPublic: false, isRequired: false, @@ -606,13 +632,13 @@ export async function createBidding(input: CreateBiddingInput, userId: string) { await tx.insert(biddingDocuments).values({ biddingId, prItemId: newPrItem.id, - documentType: 'spec', + documentType: 'spec_document', fileName: saveResult.fileName!, originalFileName: saveResult.originalName!, fileSize: saveResult.fileSize!, mimeType: file.type, - filePath: saveResult.filePath!, - publicPath: saveResult.publicPath, + filePath: saveResult.publicPath!, + // publicPath: saveResult.publicPath, title: `${prItem.itemInfo || prItem.itemCode} 스펙 - ${file.name}`, description: `PR ${prItem.prNumber}의 스펙 문서`, isPublic: false, @@ -813,3 +839,355 @@ export async function getBiddingById(id: number) { return null } } + +// 공통 결과 타입 +interface ActionResult<T> { + success: boolean + data?: T + error?: string +} + +// 사양설명회 상세 정보 타입 +export interface SpecificationMeetingDetails { + id: number + biddingId: number + meetingDate: string + meetingTime?: string | null + location: string + address?: string | null + contactPerson: string + contactPhone?: string | null + contactEmail?: string | null + agenda?: string | null + materials?: string | null + notes?: string | null + isRequired: boolean + createdAt: string + updatedAt: string + documents: Array<{ + id: number + fileName: string + originalFileName: string + fileSize: number + filePath: string + title?: string | null + uploadedAt: string + uploadedBy?: string | null + }> +} + +// PR 상세 정보 타입 +export interface PRDetails { + documents: Array<{ + id: number + documentName: string + fileName: string + originalFileName: string + fileSize: number + filePath: string + registeredAt: string + registeredBy: string + version?: string | null + description?: string | null + createdAt: string + updatedAt: string + }> + items: Array<{ + id: number + itemNumber?: string | null + itemInfo: string + quantity?: number | null + quantityUnit?: string | null + requestedDeliveryDate?: string | null + prNumber?: string | null + annualUnitPrice?: number | null + currency: string + totalWeight?: number | null + weightUnit?: string | null + materialDescription?: string | null + hasSpecDocument: boolean + createdAt: string + updatedAt: string + specDocuments: Array<{ + id: number + fileName: string + originalFileName: string + fileSize: number + filePath: string + uploadedAt: string + title?: string | null + }> + }> +} + +/** + * 사양설명회 상세 정보 조회 서버 액션 + */ +export async function getSpecificationMeetingDetailsAction( + biddingId: number +): Promise<ActionResult<SpecificationMeetingDetails>> { + try { + // 1. 입력 검증 + if (!biddingId || isNaN(biddingId) || biddingId <= 0) { + return { + success: false, + error: "유효하지 않은 입찰 ID입니다" + } + } + + // 2. 사양설명회 기본 정보 조회 + const meeting = await db + .select() + .from(specificationMeetings) + .where(eq(specificationMeetings.biddingId, biddingId)) + .limit(1) + + if (meeting.length === 0) { + return { + success: false, + error: "사양설명회 정보를 찾을 수 없습니다" + } + } + + const meetingData = meeting[0] + + // 3. 관련 문서들 조회 + const documents = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + title: biddingDocuments.title, + uploadedAt: biddingDocuments.uploadedAt, + uploadedBy: biddingDocuments.uploadedBy, + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'specification_meeting'), + eq(biddingDocuments.specificationMeetingId, meetingData.id) + ) + ) + + // 4. 데이터 직렬화 (Date 객체를 문자열로 변환) + const result: SpecificationMeetingDetails = { + id: meetingData.id, + biddingId: meetingData.biddingId, + meetingDate: meetingData.meetingDate?.toISOString() || '', + meetingTime: meetingData.meetingTime, + location: meetingData.location, + address: meetingData.address, + contactPerson: meetingData.contactPerson, + contactPhone: meetingData.contactPhone, + contactEmail: meetingData.contactEmail, + agenda: meetingData.agenda, + materials: meetingData.materials, + notes: meetingData.notes, + isRequired: meetingData.isRequired, + createdAt: meetingData.createdAt?.toISOString() || '', + updatedAt: meetingData.updatedAt?.toISOString() || '', + documents: documents.map(doc => ({ + id: doc.id, + fileName: doc.fileName, + originalFileName: doc.originalFileName, + fileSize: doc.fileSize || 0, + filePath: doc.filePath, + title: doc.title, + uploadedAt: doc.uploadedAt?.toISOString() || '', + uploadedBy: doc.uploadedBy, + })) + } + + return { + success: true, + data: result + } + + } catch (error) { + console.error("사양설명회 상세 정보 조회 실패:", error) + return { + success: false, + error: "사양설명회 정보 조회 중 오류가 발생했습니다" + } + } +} + +/** + * PR 상세 정보 조회 서버 액션 + */ +export async function getPRDetailsAction( + biddingId: number +): Promise<ActionResult<PRDetails>> { + try { + // 1. 입력 검증 + if (!biddingId || isNaN(biddingId) || biddingId <= 0) { + return { + success: false, + error: "유효하지 않은 입찰 ID입니다" + } + } + + // 2. PR 문서들 조회 + const documents = await db + .select({ + id: prDocuments.id, + documentName: prDocuments.documentName, + fileName: prDocuments.fileName, + originalFileName: prDocuments.originalFileName, + fileSize: prDocuments.fileSize, + filePath: prDocuments.filePath, + registeredAt: prDocuments.registeredAt, + registeredBy: prDocuments.registeredBy, + version: prDocuments.version, + description: prDocuments.description, + createdAt: prDocuments.createdAt, + updatedAt: prDocuments.updatedAt, + }) + .from(prDocuments) + .where(eq(prDocuments.biddingId, biddingId)) + + // 3. PR 아이템들 조회 + const items = await db + .select() + .from(prItemsForBidding) + .where(eq(prItemsForBidding.biddingId, biddingId)) + + // 4. 각 아이템별 스펙 문서들 조회 + const itemsWithDocs = await Promise.all( + items.map(async (item) => { + const specDocuments = await db + .select({ + id: biddingDocuments.id, + fileName: biddingDocuments.fileName, + originalFileName: biddingDocuments.originalFileName, + fileSize: biddingDocuments.fileSize, + filePath: biddingDocuments.filePath, + uploadedAt: biddingDocuments.uploadedAt, + title: biddingDocuments.title, + }) + .from(biddingDocuments) + .where( + and( + eq(biddingDocuments.biddingId, biddingId), + eq(biddingDocuments.documentType, 'spec_document'), + eq(biddingDocuments.prItemId, item.id) + ) + ) + + // 5. 데이터 직렬화 + return { + id: item.id, + itemNumber: item.itemNumber, + itemInfo: item.itemInfo, + quantity: item.quantity ? Number(item.quantity) : null, + quantityUnit: item.quantityUnit, + requestedDeliveryDate: item.requestedDeliveryDate?.toISOString().split('T')[0] || null, + prNumber: item.prNumber, + annualUnitPrice: item.annualUnitPrice ? Number(item.annualUnitPrice) : null, + currency: item.currency, + totalWeight: item.totalWeight ? Number(item.totalWeight) : null, + weightUnit: item.weightUnit, + materialDescription: item.materialDescription, + hasSpecDocument: item.hasSpecDocument, + createdAt: item.createdAt?.toISOString() || '', + updatedAt: item.updatedAt?.toISOString() || '', + specDocuments: specDocuments.map(doc => ({ + id: doc.id, + fileName: doc.fileName, + originalFileName: doc.originalFileName, + fileSize: doc.fileSize || 0, + filePath: doc.filePath, + uploadedAt: doc.uploadedAt?.toISOString() || '', + title: doc.title, + })) + } + }) + ) + + const result: PRDetails = { + documents: documents.map(doc => ({ + id: doc.id, + documentName: doc.documentName, + fileName: doc.fileName, + originalFileName: doc.originalFileName, + fileSize: doc.fileSize || 0, + filePath: doc.filePath, + registeredAt: doc.registeredAt?.toISOString() || '', + registeredBy: doc.registeredBy, + version: doc.version, + description: doc.description, + createdAt: doc.createdAt?.toISOString() || '', + updatedAt: doc.updatedAt?.toISOString() || '', + })), + items: itemsWithDocs + } + + return { + success: true, + data: result + } + + } catch (error) { + console.error("PR 상세 정보 조회 실패:", error) + return { + success: false, + error: "PR 정보 조회 중 오류가 발생했습니다" + } + } +} + + + +/** + * 입찰 기본 정보 조회 서버 액션 (선택사항) + */ +export async function getBiddingBasicInfoAction( + biddingId: number +): Promise<ActionResult<{ + id: number + title: string + hasSpecificationMeeting: boolean + hasPrDocument: boolean +}>> { + try { + if (!biddingId || isNaN(biddingId) || biddingId <= 0) { + return { + success: false, + error: "유효하지 않은 입찰 ID입니다" + } + } + + // 간단한 입찰 정보만 조회 (성능 최적화) + const bidding = await db.query.biddings.findFirst({ + where: (biddings, { eq }) => eq(biddings.id, biddingId), + columns: { + id: true, + title: true, + hasSpecificationMeeting: true, + hasPrDocument: true, + } + }) + + if (!bidding) { + return { + success: false, + error: "입찰 정보를 찾을 수 없습니다" + } + } + + return { + success: true, + data: bidding + } + + } catch (error) { + console.error("입찰 기본 정보 조회 실패:", error) + return { + success: false, + error: "입찰 기본 정보 조회 중 오류가 발생했습니다" + } + } +}
\ No newline at end of file diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts index 3d47aefe..556395b5 100644 --- a/lib/bidding/validation.ts +++ b/lib/bidding/validation.ts @@ -1,4 +1,4 @@ -import { biddings, type Bidding } from "@/db/schema" +import { BiddingListView, biddings, type Bidding } from "@/db/schema" import { createSearchParamsCache, parseAsArrayOf, @@ -14,7 +14,7 @@ export const searchParamsCache = createSearchParamsCache({ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<Bidding>().withDefault([ + sort: getSortingStateParser<BiddingListView>().withDefault([ { id: "createdAt", desc: true }, ]), |
