summaryrefslogtreecommitdiff
path: root/lib/bidding
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding')
-rw-r--r--lib/bidding/list/bidding-detail-dialogs.tsx754
-rw-r--r--lib/bidding/list/biddings-page-header.tsx2
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx8
-rw-r--r--lib/bidding/list/biddings-table.tsx54
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx2096
-rw-r--r--lib/bidding/service.ts424
-rw-r--r--lib/bidding/validation.ts4
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 },
]),