summaryrefslogtreecommitdiff
path: root/lib/bidding/list
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/list')
-rw-r--r--lib/bidding/list/bidding-detail-dialogs.tsx554
-rw-r--r--lib/bidding/list/bidding-pr-documents-dialog.tsx405
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx649
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx64
-rw-r--r--lib/bidding/list/biddings-table.tsx30
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx4230
6 files changed, 2908 insertions, 3024 deletions
diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx
index 4fbca616..065000ce 100644
--- a/lib/bidding/list/bidding-detail-dialogs.tsx
+++ b/lib/bidding/list/bidding-detail-dialogs.tsx
@@ -9,58 +9,49 @@ import {
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 { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { Input } from "@/components/ui/input"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
-import {
- CalendarIcon,
- ClockIcon,
+import {
+ CalendarIcon,
+ ClockIcon,
MapPinIcon,
FileTextIcon,
- DownloadIcon,
- EyeIcon,
- PackageIcon,
- HashIcon,
- DollarSignIcon,
- WeightIcon,
- ExternalLinkIcon
+ ExternalLinkIcon,
+ FileXIcon,
+ UploadIcon
} 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"
+import { getSpecificationMeetingDetailsAction } from "../service"
+import { bidClosureAction } from "../actions"
import { formatDate } from "@/lib/utils"
+import { toast } from "sonner"
// 타입 정의
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;
+ 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;
@@ -70,60 +61,13 @@ interface SpecificationMeetingDetails {
originalFileName: string;
fileSize: number;
filePath: string;
- title: string | null;
+ 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;
- }>;
+ uploadedBy?: string | null;
}>;
}
-interface ActionResult<T> {
- success: boolean;
- data?: T;
- error?: string;
-}
+// PR 관련 타입과 컴포넌트는 bidding-pr-documents-dialog.tsx로 이동됨
// 파일 다운로드 훅
const useFileDownload = () => {
@@ -212,52 +156,6 @@ const FileDownloadLink: React.FC<FileDownloadLinkProps> = ({
);
};
-// 파일 다운로드 버튼 컴포넌트 (간소화된 버전)
-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;
@@ -458,285 +356,131 @@ export function SpecificationMeetingDialog({
);
}
-// 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 || ""}`;
- };
+// PR 문서 다이얼로그는 bidding-pr-documents-dialog.tsx로 이동됨
+// import { PrDocumentsDialog } from './bidding-pr-documents-dialog'로 사용하세요
- 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>
- )}
+// 폐찰하기 다이얼로그
+interface BidClosureDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bidding: BiddingListItem | null;
+ userId: string;
+}
- {/* 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>
- )}
+export function BidClosureDialog({
+ open,
+ onOpenChange,
+ bidding,
+ userId
+}: BidClosureDialogProps) {
+ const [description, setDescription] = useState('')
+ const [files, setFiles] = useState<File[]>([])
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!bidding || !description.trim()) {
+ toast.error('폐찰 사유를 입력해주세요.')
+ return
+ }
+
+ setIsSubmitting(true)
+
+ try {
+ const result = await bidClosureAction(bidding.id, {
+ description: description.trim(),
+ files
+ }, userId)
+
+ if (result.success) {
+ toast.success(result.message)
+ onOpenChange(false)
+ // 페이지 새로고침 또는 상태 업데이트
+ window.location.reload()
+ } else {
+ toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.')
+ }
+ } catch (error) {
+ toast.error('폐찰 처리 중 오류가 발생했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files) {
+ setFiles(Array.from(e.target.files))
+ }
+ }
+
+ if (!bidding) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileXIcon className="h-5 w-5 text-destructive" />
+ 폐찰하기
+ </DialogTitle>
+ <DialogDescription>
+ {bidding.title} ({bidding.biddingNumber})를 폐찰합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label>
+ <Textarea
+ id="description"
+ placeholder="폐찰 사유를 입력해주세요..."
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="min-h-[100px]"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="files">첨부파일</Label>
+ <Input
+ id="files"
+ type="file"
+ multiple
+ onChange={handleFileChange}
+ className="cursor-pointer"
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png"
+ />
+ {files.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: {files.map(f => f.name).join(', ')}
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ variant="destructive"
+ disabled={isSubmitting || !description.trim()}
+ >
+ {isSubmitting ? '처리 중...' : '폐찰하기'}
+ </Button>
+ </div>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+}
- {/* 데이터가 없는 경우 */}
- {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
+// Re-export for backward compatibility
+export { PrDocumentsDialog } from './bidding-pr-documents-dialog' \ No newline at end of file
diff --git a/lib/bidding/list/bidding-pr-documents-dialog.tsx b/lib/bidding/list/bidding-pr-documents-dialog.tsx
new file mode 100644
index 00000000..ad377ee5
--- /dev/null
+++ b/lib/bidding/list/bidding-pr-documents-dialog.tsx
@@ -0,0 +1,405 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } 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 { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ CalendarIcon,
+ FileTextIcon,
+ PackageIcon,
+ HashIcon,
+ DollarSignIcon,
+} from "lucide-react"
+import { BiddingListItem } from "@/db/schema"
+import { formatFileSize } from "@/lib/file-download"
+import { getPRDetailsAction, type PRDetails } from "../service"
+
+// 파일 다운로드 컴포넌트
+const FileDownloadLink = ({
+ filePath,
+ fileName,
+ fileSize,
+ title,
+ className = ""
+}: {
+ filePath: string;
+ fileName: string;
+ fileSize: number;
+ title?: string | null;
+ className?: string;
+}) => {
+ return (
+ <a
+ href={filePath}
+ download={fileName}
+ className={`text-blue-600 hover:underline ${className}`}
+ title={title || fileName}
+ >
+ {title || fileName} <span className="text-xs text-gray-500">({formatFileSize(fileSize)})</span>
+ </a>
+ );
+};
+
+const FileDownloadButton = ({
+ filePath,
+ fileName,
+ variant = "download",
+ size = "sm"
+}: {
+ filePath: string;
+ fileName: string;
+ variant?: "download" | "preview";
+ size?: "sm" | "default";
+}) => {
+ return (
+ <Button
+ variant="outline"
+ size={size}
+ asChild
+ >
+ <a href={filePath} download={fileName}>
+ {variant === "download" ? "다운로드" : "미리보기"}
+ </a>
+ </Button>
+ );
+};
+
+// 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);
+
+ const fetchPRData = useCallback(async () => {
+ if (!bidding) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const result = await getPRDetailsAction(bidding.id);
+
+ if (result.success && result.data) {
+ setData(result.data as PRDetails);
+ } else {
+ setError(result.error || "PR 문서 정보를 불러올 수 없습니다.");
+ }
+ } catch (err) {
+ setError("데이터 로딩 중 오류가 발생했습니다.");
+ console.error("Failed to fetch PR data:", err);
+ } finally {
+ setLoading(false);
+ }
+ }, [bidding]);
+
+ useEffect(() => {
+ if (open && bidding) {
+ fetchPRData();
+ }
+ }, [open, bidding, fetchPRData]);
+
+ const formatCurrency = (amount: number | null, currency: string | null) => {
+ if (amount == null) return "-";
+ return `${amount.toLocaleString()} ${currency || ""}`;
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl max-h-[90vh]" style={{ maxWidth: "80vw" }}>
+ <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}
+ 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>
+ <div className="overflow-x-auto">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[80px]">아이템 번호</TableHead>
+ <TableHead className="w-[120px]">PR 번호</TableHead>
+ <TableHead className="w-[150px]">자재그룹</TableHead>
+ <TableHead className="w-[150px]">자재</TableHead>
+ <TableHead className="w-[200px]">품목정보</TableHead>
+ <TableHead className="w-[100px]">수량</TableHead>
+ <TableHead className="w-[80px]">구매단위</TableHead>
+ <TableHead className="w-[100px]">내정단가</TableHead>
+ <TableHead className="w-[100px]">내정금액</TableHead>
+ <TableHead className="w-[100px]">예산금액</TableHead>
+ <TableHead className="w-[100px]">실적금액</TableHead>
+ <TableHead className="w-[100px]">WBS코드</TableHead>
+ <TableHead className="w-[100px]">요청 납기</TableHead>
+ <TableHead className="w-[150px]">스펙 문서</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.items.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="font-medium font-mono text-xs">
+ {item.itemNumber || "-"}
+ </TableCell>
+ <TableCell className="font-mono text-xs">
+ {item.prNumber || "-"}
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.materialGroupNumber && (
+ <div className="font-mono text-xs">{item.materialGroupNumber}</div>
+ )}
+ {item.materialGroupInfo && (
+ <div className="text-xs text-muted-foreground">{item.materialGroupInfo}</div>
+ )}
+ {!item.materialGroupNumber && !item.materialGroupInfo && "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.materialNumber && (
+ <div className="font-mono text-xs">{item.materialNumber}</div>
+ )}
+ {item.materialInfo && (
+ <div className="text-xs text-muted-foreground">{item.materialInfo}</div>
+ )}
+ {!item.materialNumber && !item.materialInfo && "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-xs">
+ {item.itemInfo || "-"}
+ </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 className="text-xs">
+ {item.purchaseUnit || "-"}
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <DollarSignIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {formatCurrency(item.targetUnitPrice, item.targetCurrency)}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm font-medium">
+ {formatCurrency(item.targetAmount, item.targetCurrency)}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm">
+ {formatCurrency(item.budgetAmount, item.budgetCurrency)}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm">
+ {formatCurrency(item.actualAmount, item.actualCurrency)}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.wbsCode && (
+ <div className="font-mono text-xs">{item.wbsCode}</div>
+ )}
+ {item.wbsName && (
+ <div className="text-xs text-muted-foreground">{item.wbsName}</div>
+ )}
+ {!item.wbsCode && !item.wbsName && "-"}
+ </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) => (
+ <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>
+ </div>
+ </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>
+ );
+}
+
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index d6044e93..10966e0e 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -5,18 +5,10 @@ import { type ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import { getUserCodeByEmail } from "@/lib/bidding/service"
import {
- Eye, Edit, MoreHorizontal, FileText, Users, Calendar,
- Building, Package, DollarSign, Clock, CheckCircle, XCircle,
- AlertTriangle
+ Eye, Edit, MoreHorizontal, FileX
} from "lucide-react"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
+
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,14 +22,13 @@ import { DataTableRowAction } from "@/types/table"
// BiddingListItem에 manager 정보 추가
type BiddingListItemWithManagerCode = BiddingListItem & {
- managerName?: string | null
- managerCode?: string | null
+ bidPicName?: string | null
+ supplyPicName?: string | null
}
import {
biddingStatusLabels,
contractTypeLabels,
biddingTypeLabels,
- awardCountLabels
} from "@/db/schema"
import { formatDate } from "@/lib/utils"
@@ -68,23 +59,6 @@ const getStatusBadgeVariant = (status: string) => {
}
}
-// 금액 포맷팅
-const formatCurrency = (amount: string | number | null, currency = 'KRW') => {
- if (!amount) return '-'
-
- const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
- if (isNaN(numAmount)) return '-'
-
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- minimumFractionDigits: 0,
- maximumFractionDigits: 0,
- }).format(numAmount)
-}
-
-
-
export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingListItemWithManagerCode>[] {
return [
@@ -132,442 +106,256 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
meta: { excelHeader: "입찰 No." },
},
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+ // ░░░ 프로젝트명 ░░░
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[150px]" title={row.original.projectName || ''}>
+ {row.original.projectName || '-'}
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "프로젝트명" },
+ },
+ // ░░░ 입찰명 ░░░
+ {
+ accessorKey: "title",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.title}>
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
// ░░░ 입찰상태 ░░░
{
accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰상태" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
cell: ({ row }) => (
<Badge variant={getStatusBadgeVariant(row.original.status)}>
{biddingStatusLabels[row.original.status]}
</Badge>
),
size: 120,
- meta: { excelHeader: "입찰상태" },
+ meta: { excelHeader: "진행상태" },
},
-
- // ░░░ 긴급여부 ░░░
+ // ░░░ 입찰유형 ░░░
{
- accessorKey: "isUrgent",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="긴급여부" />,
- cell: ({ row }) => {
- const isUrgent = row.original.isUrgent
+ accessorKey: "biddingType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰유형" />,
+ cell: ({ row }) => (
+ <Badge variant="secondary">
+ {biddingTypeLabels[row.original.biddingType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "입찰유형" },
+ },
- return isUrgent ? (
- <div className="flex items-center gap-1">
- <AlertTriangle className="h-4 w-4 text-red-600" />
- <Badge variant="destructive" className="text-xs">
- 긴급
- </Badge>
- </div>
- ) : (
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="text-xs text-muted-foreground">일반</span>
- </div>
- )
- },
- size: 90,
- meta: { excelHeader: "긴급여부" },
+ // ░░░ 통화 ░░░
+ {
+ accessorKey: "currency",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.currency}</span>
+ ),
+ size: 60,
+ meta: { excelHeader: "통화" },
},
- // ░░░ 사전견적 ░░░
+ // ░░░ 예산 ░░░
{
- id: "preQuote",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사전견적" />,
- cell: ({ row }) => {
- const hasPreQuote = ['request_for_quotation', 'received_quotation'].includes(row.original.status)
- const preQuoteDate = row.original.preQuoteDate
-
- return hasPreQuote ? (
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-600" />
- {preQuoteDate && (
- <span className="text-xs text-muted-foreground">
- {formatDate(preQuoteDate, "KR")}
- </span>
- )}
- </div>
- ) : (
- <XCircle className="h-4 w-4 text-gray-400" />
- )
- },
- size: 90,
- meta: { excelHeader: "사전견적" },
+ accessorKey: "budget",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="예산" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium">
+ {row.original.budget}
+ </span>
+ ),
+ size: 120,
+ meta: { excelHeader: "예산" },
},
+ // ░░░ 내정가 ░░░
+ {
+ accessorKey: "targetPrice",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium text-orange-600">
+ {row.original.targetPrice}
+ </span>
+ ),
+ size: 120,
+ meta: { excelHeader: "내정가" },
+ },
// ░░░ 입찰담당자 ░░░
{
- accessorKey: "managerName",
+ accessorKey: "bidPicName",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
cell: ({ row }) => {
- const name = row.original.managerName || "-";
- const managerCode = row.original.managerCode || "";
- return name === "-" ? "-" : `${name}(${managerCode})`;
+ const name = row.original.bidPicName || "-";
+ return name;
},
size: 100,
meta: { excelHeader: "입찰담당자" },
},
-
- // ═══════════════════════════════════════════════════════════════
- // 프로젝트 정보
- // ═══════════════════════════════════════════════════════════════
+
+ // ░░░ 입찰등록일 ░░░
{
- header: "프로젝트 정보",
- columns: [
- {
- accessorKey: "projectName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[150px]" title={row.original.projectName || ''}>
- {row.original.projectName || '-'}
- </div>
- ),
- size: 150,
- meta: { excelHeader: "프로젝트명" },
- },
-
- {
- accessorKey: "itemName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="품목명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[150px]" title={row.original.itemName || ''}>
- {row.original.itemName || '-'}
- </div>
- ),
- size: 150,
- meta: { excelHeader: "품목명" },
- },
-
- {
- accessorKey: "title",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.original.title}>
- <Button
- variant="link"
- className="p-0 h-auto text-left justify-start font-bold underline"
- onClick={() => setRowAction({ row, type: "view" })}
- >
- <div className="whitespace-pre-line">
- {row.original.title}
- </div>
- </Button>
- </div>
- ),
- size: 200,
- meta: { excelHeader: "입찰명" },
- },
- ]
+ accessorKey: "biddingRegistrationDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.biddingRegistrationDate , "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "입찰등록일" },
},
- // ═══════════════════════════════════════════════════════════════
- // 계약 정보
- // ═══════════════════════════════════════════════════════════════
{
- header: "계약 정보",
- columns: [
- {
- accessorKey: "contractType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
- cell: ({ row }) => (
- <Badge variant="outline">
- {contractTypeLabels[row.original.contractType]}
- </Badge>
- ),
- size: 100,
- meta: { excelHeader: "계약구분" },
- },
-
- {
- accessorKey: "biddingType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰유형" />,
- cell: ({ row }) => (
- <Badge variant="secondary">
- {biddingTypeLabels[row.original.biddingType]}
- </Badge>
- ),
- size: 100,
- meta: { excelHeader: "입찰유형" },
- },
-
- {
- accessorKey: "awardCount",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="낙찰수" />,
- cell: ({ row }) => (
- <Badge variant="outline">
- {awardCountLabels[row.original.awardCount]}
- </Badge>
- ),
- size: 80,
- meta: { excelHeader: "낙찰수" },
- },
-
- {
- id: "contractPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />,
- cell: ({ row }) => {
- const startDate = row.original.contractStartDate
- const endDate = row.original.contractEndDate
-
- if (!startDate || !endDate) {
- return <span className="text-muted-foreground">-</span>
- }
-
- return (
- <div className="text-xs max-w-[120px] truncate" title={`${formatDate(startDate, "KR")} ~ ${formatDate(endDate, "KR")}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
- </div>
- )
- },
- size: 120,
- meta: { excelHeader: "계약기간" },
- },
- ]
+ id: "submissionPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
+ cell: ({ row }) => {
+ const startDate = row.original.submissionStartDate
+ const endDate = row.original.submissionEndDate
+
+ if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+
+ const now = new Date()
+ const isActive = now >= new Date(startDate) && now <= new Date(endDate)
+ const isPast = now > new Date(endDate)
+
+ return (
+ <div className="text-xs">
+ <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
+ {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ </div>
+ {isActive && (
+ <Badge variant="default" className="text-xs mt-1">진행중</Badge>
+ )}
+ </div>
+ )
+ },
+ size: 140,
+ meta: { excelHeader: "입찰서제출기간" },
},
-
- // ═══════════════════════════════════════════════════════════════
- // 일정 정보
- // ═══════════════════════════════════════════════════════════════
+ // ░░░ 사양설명회 ░░░
{
- header: "일정 정보",
- columns: [
- {
- id: "submissionPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰서제출기간" />,
- cell: ({ row }) => {
- const startDate = row.original.submissionStartDate
- const endDate = row.original.submissionEndDate
-
- if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
-
- const now = new Date()
- const isActive = now >= new Date(startDate) && now <= new Date(endDate)
- const isPast = now > new Date(endDate)
-
- return (
- <div className="text-xs">
- <div className={`${isActive ? 'text-green-600 font-medium' : isPast ? 'text-red-600' : 'text-gray-600'}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
- </div>
- {isActive && (
- <Badge variant="default" className="text-xs mt-1">진행중</Badge>
- )}
- </div>
- )
- },
- size: 140,
- meta: { excelHeader: "입찰서제출기간" },
- },
-
- {
- accessorKey: "hasSpecificationMeeting",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사양설명회" />,
- cell: ({ row }) => {
- const hasMeeting = row.original.hasSpecificationMeeting
-
- return (
- <Button
- variant="ghost"
- size="sm"
- className={`p-1 h-auto ${hasMeeting ? 'text-blue-600' : 'text-gray-400'}`}
- onClick={() => hasMeeting && setRowAction({ row, type: "specification_meeting" })}
- disabled={!hasMeeting}
- >
- {hasMeeting ? 'Yes' : 'No'}
- </Button>
- )
- },
- size: 100,
- meta: { excelHeader: "사양설명회" },
- },
- ]
+ accessorKey: "hasSpecificationMeeting",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사양설명회" />,
+ cell: ({ row }) => {
+ const hasMeeting = row.original.hasSpecificationMeeting
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className={`p-1 h-auto ${hasMeeting ? 'text-blue-600' : 'text-gray-400'}`}
+ onClick={() => hasMeeting && setRowAction({ row, type: "specification_meeting" })}
+ disabled={!hasMeeting}
+ >
+ {hasMeeting ? 'Yes' : 'No'}
+ </Button>
+ )
+ },
+ size: 100,
+ meta: { excelHeader: "사양설명회" },
},
- // ═══════════════════════════════════════════════════════════════
- // 가격 정보
- // ═══════════════════════════════════════════════════════════════
- {
- header: "가격 정보",
- columns: [
- {
- accessorKey: "currency",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.currency}</span>
- ),
- size: 60,
- meta: { excelHeader: "통화" },
- },
+ // ░░░ 등록자 ░░░
- {
- accessorKey: "budget",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="예산" />,
- cell: ({ row }) => (
- <span className="text-sm font-medium">
- {row.original.budget}
- </span>
- ),
- size: 120,
- meta: { excelHeader: "예산" },
- },
-
- {
- accessorKey: "targetPrice",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />,
- cell: ({ row }) => (
- <span className="text-sm font-medium text-orange-600">
- {row.original.targetPrice}
- </span>
- ),
- size: 120,
- meta: { excelHeader: "내정가" },
- },
-
- {
- accessorKey: "finalBidPrice",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종입찰가" />,
- cell: ({ row }) => (
- <span className="text-sm font-medium text-green-600">
- {row.original.finalBidPrice}
- </span>
- ),
- size: 120,
- meta: { excelHeader: "최종입찰가" },
- },
- ]
+ {
+ accessorKey: "updatedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.updatedBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
},
-
- // ═══════════════════════════════════════════════════════════════
- // 참여 현황
- // ═══════════════════════════════════════════════════════════════
+ // 등록일시
{
- header: "참여 현황",
- columns: [
- {
- id: "participantExpected",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정" />,
- cell: ({ row }) => (
- <Badge variant="outline" className="font-mono">
- {row.original.participantExpected}
- </Badge>
- ),
- size: 80,
- meta: { excelHeader: "참여예정" },
- },
-
- {
- id: "participantParticipated",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여" />,
- cell: ({ row }) => (
- <Badge variant="default" className="font-mono">
- {row.original.participantParticipated}
- </Badge>
- ),
- size: 60,
- meta: { excelHeader: "참여" },
- },
-
- {
- id: "participantDeclined",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기" />,
- cell: ({ row }) => (
- <Badge variant="destructive" className="font-mono">
- {row.original.participantDeclined}
- </Badge>
- ),
- size: 60,
- meta: { excelHeader: "포기" },
- },
- ]
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.updatedAt, "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록일시" },
},
-
// ═══════════════════════════════════════════════════════════════
// PR 정보
// ═══════════════════════════════════════════════════════════════
- {
- header: "PR 정보",
- columns: [
- {
- accessorKey: "prNumber",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR No." />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "PR No." },
- },
-
- {
- accessorKey: "hasPrDocument",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 문서" />,
- cell: ({ row }) => {
- const hasPrDoc = row.original.hasPrDocument
+ // {
+ // header: "PR 정보",
+ // columns: [
+ // {
+ // accessorKey: "prNumber",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR No." />,
+ // cell: ({ row }) => (
+ // <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ // ),
+ // size: 100,
+ // meta: { excelHeader: "PR No." },
+ // },
+
+ // {
+ // accessorKey: "hasPrDocument",
+ // header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 문서" />,
+ // cell: ({ row }) => {
+ // const hasPrDoc = row.original.hasPrDocument
- return (
- <Button
- variant="ghost"
- size="sm"
- className={`p-1 h-auto ${hasPrDoc ? 'text-blue-600' : 'text-gray-400'}`}
- onClick={() => hasPrDoc && setRowAction({ row, type: "pr_documents" })}
- disabled={!hasPrDoc}
- >
- {hasPrDoc ? 'Yes' : 'No'}
- </Button>
- )
- },
- size: 80,
- meta: { excelHeader: "PR 문서" },
- },
- ]
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 메타 정보
- // ═══════════════════════════════════════════════════════════════
- {
- header: "메타 정보",
- columns: [
- {
- accessorKey: "preQuoteDate",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="사전견적일" />,
- cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.preQuoteDate, "KR")}</span>
- ),
- size: 90,
- meta: { excelHeader: "사전견적일" },
- },
-
- {
- accessorKey: "biddingRegistrationDate",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰등록일" />,
- cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.biddingRegistrationDate , "KR")}</span>
- ),
- size: 100,
- meta: { excelHeader: "입찰등록일" },
- },
-
- {
- accessorKey: "updatedAt",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
- cell: ({ row }) => (
- <span className="text-sm">{formatDate(row.original.updatedAt, "KR")}</span>
- ),
- size: 100,
- meta: { excelHeader: "최종수정일" },
- },
-
- {
- accessorKey: "updatedBy",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
- cell: ({ row }) => (
- <span className="text-sm">{row.original.updatedBy || '-'}</span>
- ),
- size: 100,
- meta: { excelHeader: "최종수정자" },
- },
- ]
- },
+ // return (
+ // <Button
+ // variant="ghost"
+ // size="sm"
+ // className={`p-1 h-auto ${hasPrDoc ? 'text-blue-600' : 'text-gray-400'}`}
+ // onClick={() => hasPrDoc && setRowAction({ row, type: "pr_documents" })}
+ // disabled={!hasPrDoc}
+ // >
+ // {hasPrDoc ? 'Yes' : 'No'}
+ // </Button>
+ // )
+ // },
+ // size: 80,
+ // meta: { excelHeader: "PR 문서" },
+ // },
+ // ]
+ // },
// ░░░ 비고 ░░░
{
@@ -611,6 +399,17 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
<span className="text-xs text-muted-foreground ml-2">(수정 불가)</span>
)}
</DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "bid_closure" })}
+ disabled={row.original.status !== 'bidding_disposal'}
+ >
+ <FileX className="mr-2 h-4 w-4" />
+ 폐찰하기
+ {row.original.status !== 'bidding_disposal' && (
+ <span className="text-xs text-muted-foreground ml-2">(유찰 시에만 가능)</span>
+ )}
+ </DropdownMenuItem>
{/* <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setRowAction({ row, type: "copy" })}>
<Package className="mr-2 h-4 w-4" />
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 702396ae..0cb87b11 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -3,10 +3,9 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
import {
- Plus, Send, Download, FileSpreadsheet
+ Send, Download, FileSpreadsheet
} from "lucide-react"
import { toast } from "sonner"
-import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
@@ -14,32 +13,74 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { BiddingListItem } from "@/db/schema"
-import { CreateBiddingDialog } from "./create-bidding-dialog"
+// import { CreateBiddingDialog } from "./create-bidding-dialog"
import { TransmissionDialog } from "./biddings-transmission-dialog"
+import { BiddingCreateDialog } from "@/components/bidding/create/bidding-create-dialog"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { createBiddingSchema } from "@/lib/bidding/validation"
interface BiddingsTableToolbarActionsProps {
table: Table<BiddingListItem>
}
export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActionsProps) {
- const router = useRouter()
const { data: session } = useSession()
const [isExporting, setIsExporting] = React.useState(false)
const [isTransmissionDialogOpen, setIsTransmissionDialogOpen] = React.useState(false)
const userId = session?.user?.id ? Number(session.user.id) : 1
+ // 입찰 생성 폼
+ const form = useForm({
+ resolver: zodResolver(createBiddingSchema),
+ defaultValues: {
+ revision: 0,
+ title: '',
+ description: '',
+ content: '',
+ noticeType: 'standard' as const,
+ contractType: 'general' as const,
+ biddingType: 'equipment' as const,
+ awardCount: 'single' as const,
+ currency: 'KRW',
+ status: 'bidding_generated' as const,
+ bidPicName: '',
+ bidPicCode: '',
+ supplyPicName: '',
+ supplyPicCode: '',
+ requesterName: '',
+ attachments: [],
+ vendorAttachments: [],
+ hasSpecificationMeeting: false,
+ hasPrDocument: false,
+ isPublic: false,
+ isUrgent: false,
+ purchasingOrganization: '',
+ biddingConditions: {
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ },
+ },
+ })
+
// 선택된 입찰들
const selectedBiddings = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
.rows
.map(row => row.original)
- }, [table.getFilteredSelectedRowModel().rows])
+ }, [table])
// 업체선정이 완료된 입찰만 전송 가능
const canTransmit = selectedBiddings.length === 1 && selectedBiddings[0].status === 'vendor_selected'
@@ -52,19 +93,22 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
excludeColumns: ["select", "actions"],
})
toast.success("입찰 목록이 성공적으로 내보내졌습니다.")
- } catch (error) {
+ } catch {
toast.error("내보내기 중 오류가 발생했습니다.")
} finally {
setIsExporting(false)
}
}
+
return (
<>
<div className="flex items-center gap-2">
- {/* 신규 생성 */}
- <CreateBiddingDialog
- />
+ {/* 신규입찰 생성 버튼 */}
+ <BiddingCreateDialog form={form} onSuccess={() => {
+ // 성공 시 테이블 새로고침 등 추가 작업
+ // window.location.reload()
+ }} />
{/* 전송하기 (업체선정 완료된 입찰만) */}
<Button
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index 8920d9db..39952d5a 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -2,6 +2,7 @@
import * as React from "react"
import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
import type {
DataTableAdvancedFilterField,
DataTableFilterField,
@@ -22,7 +23,7 @@ import {
biddingTypeLabels
} from "@/db/schema"
import { EditBiddingSheet } from "./edit-bidding-sheet"
-import { SpecificationMeetingDialog, PrDocumentsDialog } from "./bidding-detail-dialogs"
+import { SpecificationMeetingDialog, PrDocumentsDialog, BidClosureDialog } from "./bidding-detail-dialogs"
interface BiddingsTableProps {
@@ -43,6 +44,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
const [isCompact, setIsCompact] = React.useState<boolean>(false)
const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [bidClosureDialogOpen, setBidClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingListItemWithManagerCode | null>(null)
console.log(data,"data")
@@ -50,6 +52,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingListItemWithManagerCode> | null>(null)
const router = useRouter()
+ const { data: session } = useSession()
const columns = React.useMemo(
() => getBiddingsColumns({ setRowAction }),
@@ -63,8 +66,8 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
switch (rowAction.type) {
case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ // 상세 페이지로 이동 (info 페이지로)
+ router.push(`/evcp/bid/${rowAction.row.original.id}/info`)
break
case "update":
// EditBiddingSheet는 아래에서 별도로 처리
@@ -75,6 +78,9 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
case "pr_documents":
setPrDocumentsDialogOpen(true)
break
+ case "bid_closure":
+ setBidClosureDialogOpen(true)
+ break
// 기존 다른 액션들은 그대로 유지
default:
break
@@ -88,10 +94,10 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
{ id: "title", label: "입찰명", type: "text" },
{ id: "biddingNumber", label: "입찰번호", type: "text" },
{ id: "projectName", label: "프로젝트명", type: "text" },
- { id: "managerName", label: "담당자", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
{
id: "status",
- label: "입찰상태",
+ label: "진행상태",
type: "multi-select",
options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
label,
@@ -154,6 +160,12 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
setSelectedBidding(null)
}, [])
+ const handleBidClosureDialogClose = React.useCallback(() => {
+ setBidClosureDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
return (
<>
@@ -195,6 +207,14 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
onOpenChange={handlePrDocumentsDialogClose}
bidding={selectedBidding}
/>
+
+ {/* 폐찰하기 다이얼로그 */}
+ <BidClosureDialog
+ open={bidClosureDialogOpen}
+ onOpenChange={handleBidClosureDialogClose}
+ bidding={selectedBidding}
+ userId={session?.user?.id ? String(session.user.id) : ''}
+ />
</>
)
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index 50246f58..20ea740f 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -1,2242 +1,2114 @@
-"use client"
+'use client'
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { Loader2, Plus, Trash2, FileText, Paperclip, CheckCircle2, ChevronRight, ChevronLeft } from "lucide-react"
-import { toast } from "sonner"
-import { useSession } from "next-auth/react"
-
-import { Button } from "@/components/ui/button"
-import {
- Dialog,
- DialogContent,
- DialogTrigger,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
- FormDescription,
-} from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Switch } from "@/components/ui/switch"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import * as React from 'react'
+import { useRouter } from 'next/navigation'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
+ Loader2,
+ Plus,
+ Trash2,
+ FileText,
+ Paperclip,
+ ChevronRight,
+ ChevronLeft,
+ X,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+
+import { Button } from '@/components/ui/button'
import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
+ Dialog,
+ DialogContent,
+ DialogTrigger,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
import {
- FileList,
- FileListAction,
- FileListDescription,
- FileListHeader,
- FileListIcon,
- FileListInfo,
- FileListItem,
- FileListName,
- FileListSize,
-} from "@/components/ui/file-list"
-import { Checkbox } from "@/components/ui/checkbox"
-
-import { createBidding, type CreateBiddingInput } from "@/lib/bidding/service"
-import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service"
-import { TAX_CONDITIONS } from "@/lib/tax-conditions/types"
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
import {
- createBiddingSchema,
- type CreateBiddingSchema
-} from "@/lib/bidding/validation"
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Tabs, TabsContent } from '@/components/ui/tabs'
+import { Checkbox } from '@/components/ui/checkbox'
+
+import { createBidding } from '@/lib/bidding/service'
import {
- biddingStatusLabels,
- contractTypeLabels,
- biddingTypeLabels,
- awardCountLabels
-} from "@/db/schema"
-import { ProjectSelector } from "@/components/ProjectSelector"
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
+ getIncotermsForSelection,
+ getPaymentTermsForSelection,
+ getPlaceOfShippingForSelection,
+ getPlaceOfDestinationForSelection,
+} from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
+import { createBiddingSchema, type CreateBiddingSchema } from '@/lib/bidding/validation'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema'
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
+import { cn } from '@/lib/utils'
+import { MaterialGroupSingleSelector } from '@/components/common/material/material-group-single-selector'
+import { MaterialSingleSelector } from '@/components/common/selectors/material/material-single-selector'
+import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector'
+import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager/procurement-manager-selector'
+import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
+import { CostCenterSingleSelector } from '@/components/common/selectors/cost-center/cost-center-single-selector'
+import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector'
+import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
+import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single'
+import { ProjectSelector } from '@/components/ProjectSelector'
// 사양설명회 정보 타입
interface SpecificationMeetingInfo {
- meetingDate: string
- meetingTime: string
- location: string
- address: string
- contactPerson: string
- contactPhone: string
- contactEmail: string
- agenda: string
- materials: string
- notes: string
- isRequired: boolean
- meetingFiles: File[] // 사양설명회 첨부파일
+ meetingDate: string
+ meetingTime: string
+ location: string
+ address: string
+ contactPerson: string
+ contactPhone: string
+ contactEmail: string
+ agenda: string
+ materials: string
+ notes: string
+ isRequired: boolean
+ meetingFiles: File[] // 사양설명회 첨부파일
}
// PR 아이템 정보 타입
interface PRItemInfo {
- id: string // 임시 ID for UI
- prNumber: string
- itemCode: string // 기존 itemNumber에서 변경
- itemInfo: string
- quantity: string
- quantityUnit: string
- totalWeight: string
- weightUnit: string
- materialDescription: string
- hasSpecDocument: boolean
- requestedDeliveryDate: string
- specFiles: File[]
- isRepresentative: boolean // 대표 아이템 여부
+ id: string // 임시 ID for UI
+ prNumber: string
+ projectId?: number // 프로젝트 ID 추가
+ projectInfo?: string // 프로젝트 정보 (기존 호환성 유지)
+ shi?: string // SHI 정보 추가
+ quantity: string
+ quantityUnit: string
+ totalWeight: string
+ weightUnit: string
+ materialDescription: string
+ hasSpecDocument: boolean
+ requestedDeliveryDate: string
+ specFiles: File[]
+ isRepresentative: boolean // 대표 아이템 여부
+ // 가격 정보
+ annualUnitPrice: string
+ currency: string
+ // 자재 그룹 정보 (필수)
+ materialGroupNumber: string // 자재그룹코드 - 필수
+ materialGroupInfo: string // 자재그룹명 - 필수
+ // 자재 정보
+ materialNumber: string // 자재코드
+ materialInfo: string // 자재명
+ // 단위 정보
+ priceUnit: string // 가격단위
+ purchaseUnit: string // 구매단위
+ materialWeight: string // 자재순중량
+ // WBS 정보
+ wbsCode: string // WBS 코드
+ wbsName: string // WBS 명칭
+ // Cost Center 정보
+ costCenterCode: string // 코스트센터 코드
+ costCenterName: string // 코스트센터 명칭
+ // GL Account 정보
+ glAccountCode: string // GL 계정 코드
+ glAccountName: string // GL 계정 명칭
+ // 내정 정보
+ targetUnitPrice: string
+ targetAmount: string
+ targetCurrency: string
+ // 예산 정보
+ budgetAmount: string
+ budgetCurrency: string
+ // 실적 정보
+ actualAmount: string
+ actualCurrency: string
}
-// 탭 순서 정의
-const TAB_ORDER = ["basic", "schedule", "details", "manager"] as const
+const TAB_ORDER = ['basic', 'schedule', 'details', 'manager'] as const
type TabType = typeof TAB_ORDER[number]
export function CreateBiddingDialog() {
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = React.useState(false)
- 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 [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false) // 닫기 확인 다이얼로그 상태
-
- // Procurement 데이터 상태들
- const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [shippingPlaces, setShippingPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [procurementLoading, setProcurementLoading] = React.useState(false)
-
- // 사양설명회 정보 상태
- const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({
- meetingDate: "",
- meetingTime: "",
- location: "",
- address: "",
- contactPerson: "",
- contactPhone: "",
- contactEmail: "",
- agenda: "",
- materials: "",
- notes: "",
- isRequired: false,
- meetingFiles: [], // 사양설명회 첨부파일
- })
-
- // PR 아이템들 상태 - 기본적으로 하나의 빈 아이템 생성
- const [prItems, setPrItems] = React.useState<PRItemInfo[]>([
- {
- id: `pr-default`,
- prNumber: "",
- itemCode: "",
- itemInfo: "",
- quantity: "",
- quantityUnit: "EA",
- totalWeight: "",
- weightUnit: "KG",
- materialDescription: "",
- hasSpecDocument: false,
- requestedDeliveryDate: "",
- specFiles: [],
- isRepresentative: true, // 첫 번째 아이템은 대표 아이템
- }
- ])
-
- // 파일 첨부를 위해 선택된 아이템 ID
- const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
-
- // 입찰 조건 상태 (기본값 설정 포함)
- const [biddingConditions, setBiddingConditions] = React.useState({
- paymentTerms: "", // 초기값 빈값, 데이터 로드 후 설정
- taxConditions: "", // 초기값 빈값, 데이터 로드 후 설정
- incoterms: "", // 초기값 빈값, 데이터 로드 후 설정
- contractDeliveryDate: "",
- shippingPort: "",
- destinationPort: "",
- isPriceAdjustmentApplicable: false,
- sparePartOptions: "",
+ const router = useRouter()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ 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 [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false)
+
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+
+ const [specMeetingInfo, setSpecMeetingInfo] =
+ React.useState<SpecificationMeetingInfo>({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ meetingFiles: [],
})
- // Procurement 데이터 로드 함수들
- const loadPaymentTerms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPaymentTermsForSelection();
- setPaymentTermsOptions(data);
- // 기본값 설정 로직: P008이 있으면 P008로, 없으면 첫 번째 항목으로 설정
- const setDefaultPaymentTerms = () => {
- const p008Exists = data.some(item => item.code === "P008");
- if (p008Exists) {
- setBiddingConditions(prev => ({ ...prev, paymentTerms: "P008" }));
- }
- };
-
- setDefaultPaymentTerms();
- } catch (error) {
- console.error("Failed to load payment terms:", error);
- toast.error("결제조건 목록을 불러오는데 실패했습니다.");
- // 에러 시 기본값 초기화
- if (biddingConditions.paymentTerms === "P008") {
- setBiddingConditions(prev => ({ ...prev, paymentTerms: "" }));
- }
- } finally {
- setProcurementLoading(false);
- }
- }, [biddingConditions.paymentTerms]);
-
- const loadIncoterms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getIncotermsForSelection();
- setIncotermsOptions(data);
-
- // 기본값 설정 로직: DAP가 있으면 DAP로, 없으면 첫 번째 항목으로 설정
- const setDefaultIncoterms = () => {
- const dapExists = data.some(item => item.code === "DAP");
- if (dapExists) {
- setBiddingConditions(prev => ({ ...prev, incoterms: "DAP" }));
- }
- };
-
- setDefaultIncoterms();
- } catch (error) {
- console.error("Failed to load incoterms:", error);
- toast.error("운송조건 목록을 불러오는데 실패했습니다.");
- } finally {
- setProcurementLoading(false);
- }
- }, [biddingConditions.incoterms]);
-
- const loadShippingPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfShippingForSelection();
- setShippingPlaces(data);
- } catch (error) {
- console.error("Failed to load shipping places:", error);
- toast.error("선적지 목록을 불러오는데 실패했습니다.");
- } finally {
- setProcurementLoading(false);
- }
- }, []);
-
- const loadDestinationPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfDestinationForSelection();
- setDestinationPlaces(data);
- } catch (error) {
- console.error("Failed to load destination places:", error);
- toast.error("하역지 목록을 불러오는데 실패했습니다.");
- } finally {
- setProcurementLoading(false);
- }
- }, []);
-
- // 다이얼로그 열릴 때 procurement 데이터 로드 및 기본값 설정
- React.useEffect(() => {
- if (open) {
- loadPaymentTerms();
- loadIncoterms();
- loadShippingPlaces();
- loadDestinationPlaces();
-
- // 세금조건 기본값 설정 (V1이 있는지 확인하고 설정)
- const v1Exists = TAX_CONDITIONS.some(item => item.code === "V1");
- if (v1Exists) {
- setBiddingConditions(prev => ({ ...prev, taxConditions: "V1" }));
- }
- }
- }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
-
-
- // 사양설명회 파일 추가
- const addMeetingFiles = (files: File[]) => {
- setSpecMeetingInfo(prev => ({
- ...prev,
- meetingFiles: [...prev.meetingFiles, ...files]
- }))
+ const [prItems, setPrItems] = React.useState<PRItemInfo[]>([
+ {
+ id: `pr-default`,
+ prNumber: '',
+ projectId: undefined,
+ projectInfo: '',
+ shi: '',
+ quantity: '',
+ quantityUnit: 'EA',
+ totalWeight: '',
+ weightUnit: 'KG',
+ materialDescription: '',
+ hasSpecDocument: false,
+ requestedDeliveryDate: '',
+ specFiles: [],
+ isRepresentative: true,
+ annualUnitPrice: '',
+ currency: 'KRW',
+ materialGroupNumber: '',
+ materialGroupInfo: '',
+ materialNumber: '',
+ materialInfo: '',
+ priceUnit: '',
+ purchaseUnit: '1',
+ materialWeight: '',
+ wbsCode: '',
+ wbsName: '',
+ costCenterCode: '',
+ costCenterName: '',
+ glAccountCode: '',
+ glAccountName: '',
+ targetUnitPrice: '',
+ targetAmount: '',
+ targetCurrency: 'KRW',
+ budgetAmount: '',
+ budgetCurrency: 'KRW',
+ actualAmount: '',
+ actualCurrency: 'KRW',
+ },
+ ])
+ const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
+ const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity')
+ const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false)
+ const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false)
+ const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false)
+ const [materialGroupDialogOpen, setMaterialGroupDialogOpen] = React.useState(false)
+ const [materialDialogOpen, setMaterialDialogOpen] = React.useState(false)
+ const [biddingConditions, setBiddingConditions] = React.useState({
+ paymentTerms: '',
+ taxConditions: '',
+ incoterms: '',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+
+ // -- 데이터 로딩 및 상태 동기화 로직
+ const loadPaymentTerms = React.useCallback(async () => {
+ try {
+ const data = await getPaymentTermsForSelection()
+ setPaymentTermsOptions(data)
+ const p008Exists = data.some((item) => item.code === 'P008')
+ if (p008Exists) {
+ setBiddingConditions((prev) => ({ ...prev, paymentTerms: 'P008' }))
+ }
+ } catch (error) {
+ console.error('Failed to load payment terms:', error)
+ toast.error('결제조건 목록을 불러오는데 실패했습니다.')
}
-
- // 사양설명회 파일 제거
- const removeMeetingFile = (fileIndex: number) => {
- setSpecMeetingInfo(prev => ({
- ...prev,
- meetingFiles: prev.meetingFiles.filter((_, index) => index !== fileIndex)
- }))
+ }, [])
+
+ const loadIncoterms = React.useCallback(async () => {
+ try {
+ const data = await getIncotermsForSelection()
+ setIncotermsOptions(data)
+ const dapExists = data.some((item) => item.code === 'DAP')
+ if (dapExists) {
+ setBiddingConditions((prev) => ({ ...prev, incoterms: 'DAP' }))
+ }
+ } catch (error) {
+ console.error('Failed to load incoterms:', error)
+ toast.error('운송조건 목록을 불러오는데 실패했습니다.')
}
-
- // PR 문서 첨부 여부 자동 계산
- const hasPrDocuments = React.useMemo(() => {
- return prItems.some(item => item.prNumber.trim() !== "" || item.specFiles.length > 0)
- }, [prItems])
-
- const form = useForm<CreateBiddingSchema>({
- resolver: zodResolver(createBiddingSchema),
- defaultValues: {
- revision: 0,
- projectId: 0, // 임시 기본값, validation에서 체크
- projectName: "",
- itemName: "",
- title: "",
- description: "",
- content: "",
-
- contractType: "general",
- biddingType: "equipment",
- biddingTypeCustom: "",
- awardCount: "single",
- contractStartDate: "",
- contractEndDate: "",
-
- submissionStartDate: "",
- submissionEndDate: "",
-
- hasSpecificationMeeting: false,
- prNumber: "",
-
- currency: "KRW",
- budget: "",
- targetPrice: "",
- finalBidPrice: "",
-
- status: "bidding_generated",
- isPublic: false,
- managerName: "",
- managerEmail: "",
- managerPhone: "",
-
- remarks: "",
- },
- })
-
- // 현재 탭 인덱스 계산
- const currentTabIndex = TAB_ORDER.indexOf(activeTab)
- const isLastTab = currentTabIndex === TAB_ORDER.length - 1
- const isFirstTab = currentTabIndex === 0
-
- // 다음/이전 탭으로 이동
- const goToNextTab = () => {
- if (!isLastTab) {
- setActiveTab(TAB_ORDER[currentTabIndex + 1])
- }
+ }, [])
+
+ const loadShippingPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfShippingForSelection()
+ setShippingPlaces(data)
+ } catch (error) {
+ console.error('Failed to load shipping places:', error)
+ toast.error('선적지 목록을 불러오는데 실패했습니다.')
}
-
- const goToPreviousTab = () => {
- if (!isFirstTab) {
- setActiveTab(TAB_ORDER[currentTabIndex - 1])
- }
+ }, [])
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfDestinationForSelection()
+ setDestinationPlaces(data)
+ } catch (error) {
+ console.error('Failed to load destination places:', error)
+ toast.error('하역지 목록을 불러오는데 실패했습니다.')
}
-
- // 탭별 validation 상태 체크
- const getTabValidationState = React.useCallback(() => {
- const formValues = form.getValues()
- const formErrors = form.formState.errors
-
- return {
- basic: {
- isValid: formValues.title.trim() !== "",
- hasErrors: !!(formErrors.title)
- },
- contract: {
- isValid: formValues.contractType &&
- formValues.biddingType &&
- formValues.awardCount &&
- formValues.contractStartDate &&
- formValues.contractEndDate &&
- formValues.currency,
- hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractStartDate || formErrors.contractEndDate || formErrors.currency)
- },
- schedule: {
- isValid: formValues.submissionStartDate &&
- formValues.submissionEndDate &&
- (!formValues.hasSpecificationMeeting ||
- (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)),
- hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate)
- },
- conditions: {
- isValid: biddingConditions.paymentTerms.trim() !== "" &&
- biddingConditions.taxConditions.trim() !== "" &&
- biddingConditions.incoterms.trim() !== "" &&
- biddingConditions.contractDeliveryDate.trim() !== "" &&
- biddingConditions.shippingPort.trim() !== "" &&
- biddingConditions.destinationPort.trim() !== "",
- hasErrors: false
- },
- details: {
- isValid: prItems.length > 0,
- hasErrors: false
- },
- manager: {
- isValid: true, // 담당자 정보는 자동 설정되므로 항상 유효
- hasErrors: !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone)
- }
- }
- }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, biddingConditions])
-
- const tabValidation = getTabValidationState()
-
- // 현재 탭이 유효한지 확인
- const isCurrentTabValid = () => {
- const validation = tabValidation[activeTab as keyof typeof tabValidation]
- return validation?.isValid ?? true
+ }, [])
+
+ React.useEffect(() => {
+ if (open) {
+ loadPaymentTerms()
+ loadIncoterms()
+ loadShippingPlaces()
+ loadDestinationPlaces()
+ const v1Exists = TAX_CONDITIONS.some((item) => item.code === 'V1')
+ if (v1Exists) {
+ setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' }))
+ }
}
-
- // 대표 PR 번호 자동 계산
- const representativePrNumber = React.useMemo(() => {
- const representativeItem = prItems.find(item => item.isRepresentative)
- return representativeItem?.prNumber || ""
- }, [prItems])
-
- // 대표 품목명 자동 계산 (첫 번째 PR 아이템의 itemInfo)
- const representativeItemName = React.useMemo(() => {
- const representativeItem = prItems.find(item => item.isRepresentative)
- return representativeItem?.itemInfo || ""
- }, [prItems])
-
- // hasPrDocument 필드와 prNumber, itemName을 자동으로 업데이트
- React.useEffect(() => {
- form.setValue("hasPrDocument", hasPrDocuments)
- form.setValue("prNumber", representativePrNumber)
- form.setValue("itemName", representativeItemName)
- }, [hasPrDocuments, representativePrNumber, representativeItemName, form])
-
-
-
- // 세션 정보로 담당자 정보 자동 채우기
- React.useEffect(() => {
- if (session?.user) {
- // 담당자명 설정
- if (session.user.name) {
- form.setValue("managerName", session.user.name)
- // 사양설명회 담당자도 동일하게 설정
- setSpecMeetingInfo(prev => ({
- ...prev,
- contactPerson: session.user.name || "",
- contactEmail: session.user.email || "",
- }))
- }
-
- // 담당자 이메일 설정
- if (session.user.email) {
- form.setValue("managerEmail", session.user.email)
- }
-
- // 담당자 전화번호는 세션에 있다면 설정 (보통 세션에 전화번호는 없지만, 있다면)
- if ('phone' in session.user && session.user.phone) {
- form.setValue("managerPhone", session.user.phone as string)
- }
- }
- }, [session, form])
-
- // PR 아이템 추가
- const addPRItem = () => {
- const newItem: PRItemInfo = {
- id: `pr-${Math.random().toString(36).substr(2, 9)}`,
- prNumber: "",
- itemCode: "",
- itemInfo: "",
- quantity: "",
- quantityUnit: "EA",
- totalWeight: "",
- weightUnit: "KG",
- materialDescription: "",
- hasSpecDocument: false,
- requestedDeliveryDate: "",
- specFiles: [],
- isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템
- }
- setPrItems(prev => [...prev, newItem])
+ }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
+
+ const hasPrDocuments = React.useMemo(() => {
+ return prItems.some((item) => item.prNumber.trim() !== '' || item.specFiles.length > 0)
+ }, [prItems])
+
+ const form = useForm<CreateBiddingSchema>({
+ resolver: zodResolver(createBiddingSchema),
+ defaultValues: {
+ revision: 0,
+ projectId: 0,
+ projectName: '',
+ itemName: '',
+ title: '',
+ description: '',
+ content: '',
+ contractType: 'general',
+ biddingType: 'equipment',
+ biddingTypeCustom: '',
+ awardCount: 'single',
+ contractStartDate: '',
+ contractEndDate: '',
+ submissionStartDate: '',
+ submissionEndDate: '',
+ hasSpecificationMeeting: false,
+ prNumber: '',
+ currency: 'KRW',
+ status: 'bidding_generated',
+ isPublic: false,
+ purchasingOrganization: '',
+ managerName: '',
+ managerEmail: '',
+ managerPhone: '',
+ remarks: '',
+ },
+ })
+
+ const currentTabIndex = TAB_ORDER.indexOf(activeTab)
+ const isLastTab = currentTabIndex === TAB_ORDER.length - 1
+ const isFirstTab = currentTabIndex === 0
+
+ const goToNextTab = () => {
+ if (!isLastTab) {
+ setActiveTab(TAB_ORDER[currentTabIndex + 1])
}
+ }
- // PR 아이템 제거
- const removePRItem = (id: string) => {
- // 최소 하나의 아이템은 유지해야 함
- if (prItems.length <= 1) {
- toast.error("최소 하나의 품목이 필요합니다.")
- return
- }
-
- setPrItems(prev => {
- const filteredItems = prev.filter(item => item.id !== id)
- // 만약 대표 아이템을 삭제했다면, 첫 번째 아이템을 대표로 설정
- const removedItem = prev.find(item => item.id === id)
- if (removedItem?.isRepresentative && filteredItems.length > 0) {
- filteredItems[0].isRepresentative = true
- }
- return filteredItems
- })
- // 파일 첨부 중인 아이템이면 선택 해제
- if (selectedItemForFile === id) {
- setSelectedItemForFile(null)
- }
+ const goToPreviousTab = () => {
+ if (!isFirstTab) {
+ setActiveTab(TAB_ORDER[currentTabIndex - 1])
}
-
- // PR 아이템 업데이트
- const updatePRItem = (id: string, updates: Partial<PRItemInfo>) => {
- setPrItems(prev => prev.map(item =>
- item.id === id ? { ...item, ...updates } : item
- ))
+ }
+
+ const getTabValidationState = React.useCallback(() => {
+ const formValues = form.getValues()
+ const formErrors = form.formState.errors
+
+ return {
+ basic: {
+ isValid: formValues.title.trim() !== '',
+ hasErrors: !!formErrors.title,
+ },
+ schedule: {
+ isValid:
+ formValues.submissionStartDate &&
+ formValues.submissionEndDate &&
+ (!formValues.hasSpecificationMeeting ||
+ (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)),
+ hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate),
+ },
+ details: {
+ // 임시로 자재그룹코드 필수 체크 해제
+ // isValid: prItems.length > 0 && prItems.every(item => item.materialGroupNumber.trim() !== ''),
+ isValid: prItems.length > 0,
+ hasErrors: false,
+ },
+ manager: {
+ // 임시로 담당자 필수 체크 해제
+ isValid: true,
+ hasErrors: false, // !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone),
+ },
}
-
- // 대표 아이템 설정 (하나만 선택 가능)
- const setRepresentativeItem = (id: string) => {
- setPrItems(prev => prev.map(item => ({
- ...item,
- isRepresentative: item.id === id
- })))
+ }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, prItems])
+
+ const tabValidation = getTabValidationState()
+
+ const isCurrentTabValid = () => {
+ const validation = tabValidation[activeTab as keyof typeof tabValidation]
+ return validation?.isValid ?? true
+ }
+
+ const representativePrNumber = React.useMemo(() => {
+ const representativeItem = prItems.find((item) => item.isRepresentative)
+ return representativeItem?.prNumber || ''
+ }, [prItems])
+
+ const representativeItemName = React.useMemo(() => {
+ const representativeItem = prItems.find((item) => item.isRepresentative)
+ return representativeItem?.materialGroupInfo || ''
+ }, [prItems])
+
+ React.useEffect(() => {
+ form.setValue('hasPrDocument', hasPrDocuments)
+ form.setValue('prNumber', representativePrNumber)
+ form.setValue('itemName', representativeItemName)
+ }, [hasPrDocuments, representativePrNumber, representativeItemName, form])
+
+ const addPRItem = () => {
+ const newItem: PRItemInfo = {
+ id: `pr-${Math.random().toString(36).substr(2, 9)}`,
+ prNumber: '',
+ projectId: undefined,
+ projectInfo: '',
+ shi: '',
+ quantity: '',
+ quantityUnit: 'EA',
+ totalWeight: '',
+ weightUnit: 'KG',
+ materialDescription: '',
+ hasSpecDocument: false,
+ requestedDeliveryDate: '',
+ specFiles: [],
+ isRepresentative: prItems.length === 0,
+ annualUnitPrice: '',
+ currency: 'KRW',
+ materialGroupNumber: '',
+ materialGroupInfo: '',
+ materialNumber: '',
+ materialInfo: '',
+ priceUnit: '',
+ purchaseUnit: '1',
+ materialWeight: '',
+ wbsCode: '',
+ wbsName: '',
+ costCenterCode: '',
+ costCenterName: '',
+ glAccountCode: '',
+ glAccountName: '',
+ targetUnitPrice: '',
+ targetAmount: '',
+ targetCurrency: 'KRW',
+ budgetAmount: '',
+ budgetCurrency: 'KRW',
+ actualAmount: '',
+ actualCurrency: 'KRW',
}
+ setPrItems((prev) => [...prev, newItem])
+ }
- // 스펙 파일 추가
- const addSpecFiles = (itemId: string, files: File[]) => {
- updatePRItem(itemId, {
- specFiles: [...(prItems.find(item => item.id === itemId)?.specFiles || []), ...files]
- })
- // 파일 추가 후 선택 해제
- setSelectedItemForFile(null)
+ const removePRItem = (id: string) => {
+ if (prItems.length <= 1) {
+ toast.error('최소 하나의 품목이 필요합니다.')
+ return
}
- // 스펙 파일 제거
- const removeSpecFile = (itemId: string, fileIndex: number) => {
- const item = prItems.find(item => item.id === itemId)
- if (item) {
- const newFiles = item.specFiles.filter((_, index) => index !== fileIndex)
- updatePRItem(itemId, { specFiles: newFiles })
+ setPrItems((prev) => {
+ const filteredItems = prev.filter((item) => item.id !== id)
+ const removedItem = prev.find((item) => item.id === id)
+ if (removedItem?.isRepresentative && filteredItems.length > 0) {
+ filteredItems[0].isRepresentative = true
+ }
+ return filteredItems
+ })
+ if (selectedItemForFile === id) {
+ setSelectedItemForFile(null)
+ }
+ }
+
+ const updatePRItem = (id: string, updates: Partial<PRItemInfo>) => {
+ setPrItems((prev) =>
+ prev.map((item) => {
+ if (item.id === id) {
+ const updatedItem = { ...item, ...updates }
+ // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산
+ if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) {
+ updatedItem.targetAmount = calculateTargetAmount(updatedItem)
+ }
+ return updatedItem
}
+ return item
+ })
+ )
+ }
+
+ const setRepresentativeItem = (id: string) => {
+ setPrItems((prev) =>
+ prev.map((item) => ({
+ ...item,
+ isRepresentative: item.id === id,
+ }))
+ )
+ }
+
+ const handleQuantityWeightModeChange = (mode: 'quantity' | 'weight') => {
+ setQuantityWeightMode(mode)
+ }
+
+ const calculateTargetAmount = (item: PRItemInfo) => {
+ const unitPrice = parseFloat(item.targetUnitPrice) || 0
+ const purchaseUnit = parseFloat(item.purchaseUnit) || 1 // 기본값 1
+ let amount = 0
+
+ if (quantityWeightMode === 'quantity') {
+ const quantity = parseFloat(item.quantity) || 0
+ // (수량 / 구매단위) * 내정단가
+ amount = (quantity / purchaseUnit) * unitPrice
+ } else {
+ const weight = parseFloat(item.totalWeight) || 0
+ // (중량 / 구매단위) * 내정단가
+ amount = (weight / purchaseUnit) * unitPrice
}
- // ✅ 프로젝트 선택 핸들러
- const handleProjectSelect = React.useCallback((project: { id: number; code: string; name: string } | null) => {
- if (project) {
- form.setValue("projectId", project.id)
- } else {
- form.setValue("projectId", 0)
- }
- }, [form])
-
-
- // 다음 버튼 클릭 핸들러
- const handleNextClick = () => {
- // 현재 탭 validation 체크
- if (!isCurrentTabValid()) {
- // 특정 탭별 에러 메시지
- if (activeTab === "basic") {
- toast.error("기본 정보를 모두 입력해주세요 (품목명, 입찰명)")
- } else if (activeTab === "contract") {
- toast.error("계약 정보를 모두 입력해주세요")
- } else if (activeTab === "schedule") {
- if (form.watch("hasSpecificationMeeting")) {
- toast.error("사양설명회 필수 정보를 입력해주세요 (회의일시, 장소, 담당자)")
- } else {
- toast.error("제출 시작일시와 마감일시를 입력해주세요")
- }
- } else if (activeTab === "conditions") {
- toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일)")
- } else if (activeTab === "details") {
- toast.error("품목정보, 수량/단위 또는 중량/중량단위를 입력해주세요")
- }
- return
- }
+ // 소수점 버림
+ return Math.floor(amount).toString()
+ }
- goToNextTab()
+ const addSpecFiles = (itemId: string, files: File[]) => {
+ updatePRItem(itemId, {
+ specFiles: [...(prItems.find((item) => item.id === itemId)?.specFiles || []), ...files],
+ })
+ setSelectedItemForFile(null)
+ }
+
+ const removeSpecFile = (itemId: string, fileIndex: number) => {
+ const item = prItems.find((item) => item.id === itemId)
+ if (item) {
+ const newFiles = item.specFiles.filter((_, index) => index !== fileIndex)
+ updatePRItem(itemId, { specFiles: newFiles })
}
-
- // 폼 제출
- async function onSubmit(data: CreateBiddingSchema) {
- // 사양설명회 필수값 검증
- if (data.hasSpecificationMeeting) {
- const requiredFields = [
- { field: specMeetingInfo.meetingDate, name: "회의일시" },
- { field: specMeetingInfo.location, name: "회의 장소" },
- { field: specMeetingInfo.contactPerson, name: "담당자" }
- ]
-
- const missingFields = requiredFields.filter(item => !item.field.trim())
- if (missingFields.length > 0) {
- toast.error(`사양설명회 필수 정보가 누락되었습니다: ${missingFields.map(f => f.name).join(", ")}`)
- setActiveTab("schedule")
- return
- }
+ }
+
+ const handleNextClick = () => {
+ if (!isCurrentTabValid()) {
+ if (activeTab === 'basic') {
+ toast.error('기본 정보를 모두 입력해주세요.')
+ } else if (activeTab === 'schedule') {
+ if (form.watch('hasSpecificationMeeting')) {
+ toast.error('사양설명회 필수 정보를 입력해주세요.')
+ } else {
+ toast.error('제출 시작일시와 마감일시를 입력해주세요.')
}
+ } else if (activeTab === 'details') {
+ toast.error('최소 하나의 아이템이 필요하며, 모든 아이템에 자재그룹코드가 필수입니다.')
+ }
+ return
+ }
- setIsSubmitting(true)
- try {
- const userId = session?.user?.id?.toString() || "1"
-
- // 추가 데이터 준비
- const extendedData = {
- ...data,
- hasPrDocument: hasPrDocuments, // 자동 계산된 값 사용
- prNumber: representativePrNumber, // 대표 아이템의 PR 번호 사용
- specificationMeeting: data.hasSpecificationMeeting ? {
- ...specMeetingInfo,
- meetingFiles: specMeetingInfo.meetingFiles
- } : null,
- prItems: prItems.length > 0 ? prItems : [],
- biddingConditions: biddingConditions,
- }
-
- const result = await createBidding(extendedData, userId)
-
- if (result.success) {
- toast.success((result as { success: true; message: string }).message || "입찰이 성공적으로 생성되었습니다.")
- setOpen(false)
- router.refresh()
-
- // 생성된 입찰 상세페이지로 이동할지 묻기
- if (result.success && 'data' in result && result.data?.id) {
- setCreatedBiddingId(result.data.id)
- setShowSuccessDialog(true)
- }
- } else {
- const errorMessage = result.success === false && 'error' in result ? result.error : "입찰 생성에 실패했습니다."
- toast.error(errorMessage)
- }
- } catch (error) {
- console.error("Error creating bidding:", error)
- toast.error("입찰 생성 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
+ goToNextTab()
+ }
+
+ async function onSubmit(data: CreateBiddingSchema) {
+ if (data.hasSpecificationMeeting) {
+ const requiredFields = [
+ { field: specMeetingInfo.meetingDate, name: '회의일시' },
+ { field: specMeetingInfo.location, name: '회의 장소' },
+ { field: specMeetingInfo.contactPerson, name: '담당자' },
+ ]
+
+ const missingFields = requiredFields.filter((item) => !item.field.trim())
+ if (missingFields.length > 0) {
+ toast.error(`사양설명회 필수 정보가 누락되었습니다: ${missingFields.map((f) => f.name).join(', ')}`)
+ setActiveTab('schedule')
+ return
+ }
}
- // 폼 및 상태 초기화 함수
- const resetAllStates = React.useCallback(() => {
- // 폼 초기화
- form.reset({
- revision: 0,
- projectId: 0,
- projectName: "",
- itemName: "",
- title: "",
- description: "",
- content: "",
- contractType: "general",
- biddingType: "equipment",
- biddingTypeCustom: "",
- awardCount: "single",
- contractStartDate: "",
- contractEndDate: "",
- submissionStartDate: "",
- submissionEndDate: "",
- hasSpecificationMeeting: false,
- prNumber: "",
- currency: "KRW",
- status: "bidding_generated",
- isPublic: false,
- managerName: "",
- managerEmail: "",
- managerPhone: "",
- remarks: "",
- })
-
- // 추가 상태들 초기화
- setSpecMeetingInfo({
- meetingDate: "",
- meetingTime: "",
- location: "",
- address: "",
- contactPerson: "",
- contactPhone: "",
- contactEmail: "",
- agenda: "",
- materials: "",
- notes: "",
- isRequired: false,
- meetingFiles: [],
- })
- setPrItems([
- {
- id: `pr-default`,
- prNumber: "",
- itemCode: "",
- itemInfo: "",
- quantity: "",
- quantityUnit: "EA",
- totalWeight: "",
- weightUnit: "KG",
- materialDescription: "",
- hasSpecDocument: false,
- requestedDeliveryDate: "",
- specFiles: [],
- isRepresentative: true, // 첫 번째 아이템은 대표 아이템
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+
+ const extendedData = {
+ ...data,
+ hasPrDocument: hasPrDocuments,
+ prNumber: representativePrNumber,
+ specificationMeeting: data.hasSpecificationMeeting
+ ? {
+ ...specMeetingInfo,
+ meetingFiles: specMeetingInfo.meetingFiles,
}
- ])
- setSelectedItemForFile(null)
- setBiddingConditions({
- paymentTerms: "",
- taxConditions: "",
- incoterms: "",
- contractDeliveryDate: "",
- shippingPort: "",
- destinationPort: "",
- isPriceAdjustmentApplicable: false,
- sparePartOptions: "",
- })
- setActiveTab("basic")
- setShowSuccessDialog(false) // 추가
- setCreatedBiddingId(null) // 추가
- }, [form])
-
- // 다이얼로그 핸들러
- function handleDialogOpenChange(nextOpen: boolean) {
- if (!nextOpen) {
- // 닫으려 할 때 확인 창을 먼저 띄움
- setShowCloseConfirmDialog(true)
- } else {
- // 열 때는 바로 적용
- setOpen(nextOpen)
+ : null,
+ prItems: prItems.length > 0 ? prItems : [],
+ biddingConditions: biddingConditions,
+ }
+
+ const result = await createBidding(extendedData, userId)
+
+ if (result.success) {
+ toast.success(
+ (result as { success: true; message: string }).message || '입찰이 성공적으로 생성되었습니다.'
+ )
+ setOpen(false)
+ router.refresh()
+ if (result.success && 'data' in result && result.data?.id) {
+ setCreatedBiddingId(result.data.id)
+ setShowSuccessDialog(true)
}
+ } else {
+ const errorMessage =
+ result.success === false && 'error' in result ? result.error : '입찰 생성에 실패했습니다.'
+ toast.error(errorMessage)
+ }
+ } catch (error) {
+ console.error('Error creating bidding:', error)
+ toast.error('입찰 생성 중 오류가 발생했습니다.')
+ } finally {
+ setIsSubmitting(false)
}
+ }
+
+ const resetAllStates = React.useCallback(() => {
+ form.reset({
+ revision: 0,
+ projectId: 0,
+ projectName: '',
+ itemName: '',
+ title: '',
+ description: '',
+ content: '',
+ contractType: 'general',
+ biddingType: 'equipment',
+ biddingTypeCustom: '',
+ awardCount: 'single',
+ contractStartDate: '',
+ contractEndDate: '',
+ submissionStartDate: '',
+ submissionEndDate: '',
+ hasSpecificationMeeting: false,
+ prNumber: '',
+ currency: 'KRW',
+ status: 'bidding_generated',
+ isPublic: false,
+ purchasingOrganization: '',
+ managerName: '',
+ managerEmail: '',
+ managerPhone: '',
+ remarks: '',
+ })
- // 닫기 확인 핸들러
- const handleCloseConfirm = (confirmed: boolean) => {
- setShowCloseConfirmDialog(false)
- if (confirmed) {
- // 사용자가 "예"를 선택한 경우 실제로 닫기
- resetAllStates()
- setOpen(false)
- }
- // "아니오"를 선택한 경우는 아무것도 하지 않음 (다이얼로그 유지)
+ setSpecMeetingInfo({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ meetingFiles: [],
+ })
+ setPrItems([
+ {
+ id: `pr-default`,
+ prNumber: '',
+ projectId: undefined,
+ projectInfo: '',
+ shi: '',
+ quantity: '',
+ quantityUnit: 'EA',
+ totalWeight: '',
+ weightUnit: 'KG',
+ materialDescription: '',
+ hasSpecDocument: false,
+ requestedDeliveryDate: '',
+ specFiles: [],
+ isRepresentative: true,
+ annualUnitPrice: '',
+ currency: 'KRW',
+ materialGroupNumber: '',
+ materialGroupInfo: '',
+ materialNumber: '',
+ materialInfo: '',
+ priceUnit: '',
+ purchaseUnit: '',
+ materialWeight: '',
+ wbsCode: '',
+ wbsName: '',
+ costCenterCode: '',
+ costCenterName: '',
+ glAccountCode: '',
+ glAccountName: '',
+ targetUnitPrice: '',
+ targetAmount: '',
+ targetCurrency: 'KRW',
+ budgetAmount: '',
+ budgetCurrency: 'KRW',
+ actualAmount: '',
+ actualCurrency: 'KRW',
+ },
+ ])
+ setSelectedItemForFile(null)
+ setCostCenterDialogOpen(false)
+ setGlAccountDialogOpen(false)
+ setWbsCodeDialogOpen(false)
+ setMaterialGroupDialogOpen(false)
+ setMaterialDialogOpen(false)
+ setBiddingConditions({
+ paymentTerms: '',
+ taxConditions: '',
+ incoterms: '',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+ setActiveTab('basic')
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }, [form])
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ setShowCloseConfirmDialog(true)
+ } else {
+ setOpen(nextOpen)
}
+ }
- // 입찰 생성 버튼 클릭 핸들러 추가
- const handleCreateBidding = () => {
- // 마지막 탭 validation 체크
- if (!isCurrentTabValid()) {
- toast.error("필수 정보를 모두 입력해주세요.")
- return
- }
-
- // 수동으로 폼 제출
- form.handleSubmit(onSubmit)()
+ const handleCloseConfirm = (confirmed: boolean) => {
+ setShowCloseConfirmDialog(false)
+ if (confirmed) {
+ resetAllStates()
+ setOpen(false)
}
+ }
- // 성공 다이얼로그 핸들러들
- const handleNavigateToDetail = () => {
- if (createdBiddingId) {
- router.push(`/evcp/bid/${createdBiddingId}`)
- }
- setShowSuccessDialog(false)
- setCreatedBiddingId(null)
+ const handleCreateBidding = () => {
+ if (!isCurrentTabValid()) {
+ toast.error('필수 정보를 모두 입력해주세요.')
+ return
}
- const handleStayOnPage = () => {
- setShowSuccessDialog(false)
- setCreatedBiddingId(null)
+ form.handleSubmit(onSubmit)()
+ }
+
+ const handleNavigateToDetail = () => {
+ if (createdBiddingId) {
+ router.push(`/evcp/bid/${createdBiddingId}`)
}
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }
+ const handleStayOnPage = () => {
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }
+ // PR 아이템 테이블 렌더링
+ const renderPrItemsTable = () => {
return (
- <>
- <Dialog open={open} onOpenChange={handleDialogOpenChange}>
- <DialogTrigger asChild>
- <Button variant="default" size="sm">
- <Plus className="mr-2 h-4 w-4" />
- 신규 입찰
+ <div className="border rounded-lg overflow-hidden">
+ <div className="overflow-x-auto">
+ <table className="w-full border-collapse">
+ <thead className="bg-muted/50">
+ <tr>
+ <th className="sticky left-0 z-10 bg-muted/50 border-r px-2 py-3 text-left text-xs font-medium min-w-[50px]">
+ <span className="sr-only">대표</span>
+ </th>
+ <th className="sticky left-[50px] z-10 bg-muted/50 border-r px-3 py-3 text-left text-xs font-medium min-w-[40px]">
+ #
+ </th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">예산금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th>
+ <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]">
+ 액션
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {prItems.map((item, index) => (
+ <tr key={item.id} className="border-t hover:bg-muted/30">
+ <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center">
+ <Checkbox
+ checked={item.isRepresentative}
+ onCheckedChange={() => setRepresentativeItem(item.id)}
+ disabled={prItems.length <= 1 && item.isRepresentative}
+ title="대표 아이템"
+ />
+ </td>
+ <td className="sticky left-[50px] z-10 bg-background border-r px-3 py-2 text-xs text-muted-foreground">
+ {index + 1}
+ </td>
+ <td className="border-r px-3 py-2">
+ <ProjectSelector
+ selectedProjectId={item.projectId || null}
+ onProjectSelect={(project) => {
+ updatePRItem(item.id, {
+ projectId: project.id,
+ projectInfo: project.projectName
+ })
+ }}
+ placeholder="프로젝트 선택"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="프로젝트명"
+ value={item.projectInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupNumber ? {
+ materialGroupCode: item.materialGroupNumber,
+ materialGroupDescription: item.materialGroupInfo,
+ displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialGroupNumber: material.materialGroupCode,
+ materialGroupInfo: material.materialGroupDescription
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재그룹명"
+ value={item.materialGroupInfo}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialSelectorDialogSingle
+ triggerLabel={item.materialNumber || "자재 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialNumber ? {
+ materialCode: item.materialNumber,
+ materialName: item.materialInfo,
+ displayText: `${item.materialNumber} - ${item.materialInfo}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialNumber: material.materialCode,
+ materialInfo: material.materialName
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialNumber: '',
+ materialInfo: ''
+ })
+ }
+ }}
+ title="자재 선택"
+ description="자재를 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재명"
+ value={item.materialInfo}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Input
+ type="number"
+ min="0"
+ placeholder="수량"
+ value={item.quantity}
+ onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
+ className="h-8 text-xs"
+ />
+ ) : (
+ <Input
+ type="number"
+ min="0"
+ placeholder="중량"
+ value={item.totalWeight}
+ onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
+ className="h-8 text-xs"
+ />
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Select
+ value={item.quantityUnit}
+ onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <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>
+ ) : (
+ <Select
+ value={item.weightUnit}
+ onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KG">KG</SelectItem>
+ <SelectItem value="TON">TON</SelectItem>
+ <SelectItem value="G">G</SelectItem>
+ <SelectItem value="LB">LB</SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="1"
+ step="1"
+ placeholder="구매단위"
+ value={item.purchaseUnit || ''}
+ onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정단가"
+ value={item.targetUnitPrice || ''}
+ onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정금액"
+ readOnly
+ value={item.targetAmount || ''}
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.targetCurrency}
+ onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="예산금액"
+ value={item.budgetAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.budgetCurrency}
+ onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="실적금액"
+ value={item.actualAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.actualCurrency}
+ onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => setWbsCodeDialogOpen(true)}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.wbsCode ? (
+ <span className="truncate">
+ {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">WBS 코드 선택</span>
+ )}
+ </Button>
+ <WbsCodeSingleSelector
+ open={wbsCodeDialogOpen}
+ onOpenChange={setWbsCodeDialogOpen}
+ selectedCode={item.wbsCode ? {
+ PROJ_NO: '',
+ WBS_ELMT: item.wbsCode,
+ WBS_ELMT_NM: item.wbsName || '',
+ WBS_LVL: ''
+ } : undefined}
+ onCodeSelect={(wbsCode) => {
+ updatePRItem(item.id, {
+ wbsCode: wbsCode.WBS_ELMT,
+ wbsName: wbsCode.WBS_ELMT_NM
+ })
+ setWbsCodeDialogOpen(false)
+ }}
+ title="WBS 코드 선택"
+ description="WBS 코드를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="WBS명"
+ value={item.wbsName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => setCostCenterDialogOpen(true)}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.costCenterCode ? (
+ <span className="truncate">
+ {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">코스트센터 선택</span>
+ )}
</Button>
- </DialogTrigger>
- <DialogContent className="max-w-7xl h-[90vh] p-0 flex flex-col">
- {/* 고정 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle>신규 입찰 생성</DialogTitle>
- <DialogDescription>
- 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
- </DialogDescription>
- </DialogHeader>
+ <CostCenterSingleSelector
+ open={costCenterDialogOpen}
+ onOpenChange={setCostCenterDialogOpen}
+ selectedCode={item.costCenterCode ? {
+ KOSTL: item.costCenterCode,
+ KTEXT: '',
+ LTEXT: item.costCenterName || '',
+ DATAB: '',
+ DATBI: ''
+ } : undefined}
+ onCodeSelect={(costCenter) => {
+ updatePRItem(item.id, {
+ costCenterCode: costCenter.KOSTL,
+ costCenterName: costCenter.LTEXT
+ })
+ setCostCenterDialogOpen(false)
+ }}
+ title="코스트센터 선택"
+ description="코스트센터를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="코스트센터명"
+ value={item.costCenterName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => setGlAccountDialogOpen(true)}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.glAccountCode ? (
+ <span className="truncate">
+ {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">GL계정 선택</span>
+ )}
+ </Button>
+ <GlAccountSingleSelector
+ open={glAccountDialogOpen}
+ onOpenChange={setGlAccountDialogOpen}
+ selectedCode={item.glAccountCode ? {
+ SAKNR: item.glAccountCode,
+ FIPEX: '',
+ TEXT1: item.glAccountName || ''
+ } : undefined}
+ onCodeSelect={(glAccount) => {
+ updatePRItem(item.id, {
+ glAccountCode: glAccount.SAKNR,
+ glAccountName: glAccount.TEXT1
+ })
+ setGlAccountDialogOpen(false)
+ }}
+ title="GL 계정 선택"
+ description="GL 계정을 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="GL계정명"
+ value={item.glAccountName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="date"
+ value={item.requestedDeliveryDate}
+ onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="sticky right-0 z-10 bg-background border-l px-3 py-2">
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ const fileInput = document.createElement('input')
+ fileInput.type = 'file'
+ fileInput.multiple = true
+ fileInput.accept = '.pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg'
+ fileInput.onchange = (e) => {
+ const files = Array.from((e.target as HTMLInputElement).files || [])
+ if (files.length > 0) {
+ addSpecFiles(item.id, files)
+ }
+ }
+ fileInput.click()
+ }}
+ className="h-7 w-7 p-0"
+ title="파일 첨부"
+ >
+ <Paperclip className="h-3.5 w-3.5" />
+ {item.specFiles.length > 0 && (
+ <span className="absolute -top-1 -right-1 bg-primary text-primary-foreground rounded-full w-4 h-4 text-[10px] flex items-center justify-center">
+ {item.specFiles.length}
+ </span>
+ )}
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removePRItem(item.id)}
+ disabled={prItems.length <= 1}
+ className="h-7 w-7 p-0"
+ title="품목 삭제"
+ >
+ <Trash2 className="h-3.5 w-3.5" />
+ </Button>
</div>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- id="create-bidding-form"
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+
+ {/* 첨부된 파일 목록 표시 */}
+ {prItems.some(item => item.specFiles.length > 0) && (
+ <div className="border-t p-4 bg-muted/20">
+ <Label className="text-sm font-medium mb-2 block">첨부된 스펙 파일</Label>
+ <div className="space-y-3">
+ {prItems.map((item, index) => (
+ item.specFiles.length > 0 && (
+ <div key={item.id} className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">
+ {item.materialGroupInfo || item.materialGroupNumber || `ITEM-${index + 1}`}
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {item.specFiles.map((file, fileIndex) => (
+ <div
+ key={fileIndex}
+ className="inline-flex items-center gap-1 px-2 py-1 bg-background border rounded text-xs"
+ >
+ <Paperclip className="h-3 w-3" />
+ <span className="max-w-[200px] truncate">{file.name}</span>
+ <span className="text-muted-foreground">
+ ({(file.size / 1024 / 1024).toFixed(2)} MB)
+ </span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeSpecFile(item.id, fileIndex)}
+ className="h-4 w-4 p-0 ml-1 hover:bg-destructive/20"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )
+ }
+
+
+ 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="h-[90vh] p-0 flex flex-col" style={{ maxWidth: '1400px' }}>
+ {/* 고정 헤더 */}
+ <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={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col">
+ {/* 탭 리스트 */}
+ <div className="px-6">
+ <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto">
+ {TAB_ORDER.map((tab) => (
+ <button
+ key={tab}
+ type="button"
+ onClick={() => setActiveTab(tab)}
+ className={cn(
+ 'relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0',
+ activeTab === tab ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
+ )}
>
- {/* 탭 영역 */}
- <div className="flex-1 overflow-hidden">
- <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col">
- <div className="px-6">
- <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto">
- <button
- type="button"
- onClick={() => setActiveTab("basic")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "basic"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 기본정보
- {!tabValidation.basic.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("schedule")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "schedule"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 입찰계획
- {!tabValidation.schedule.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("details")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "details"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 세부내역
- {!tabValidation.details.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("manager")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "manager"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 담당자
- </button>
- </div>
- </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="projectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 프로젝트
- </FormLabel>
- <FormControl>
- <ProjectSelector
- selectedProjectId={field.value}
- onProjectSelect={handleProjectSelect}
- placeholder="프로젝트 선택..."
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* <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="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>
- )}
- />
-
- {/* 계약 정보 섹션 */}
- <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>
- )}
- />
-
- {/* 기타 입찰유형 직접입력 */}
- {form.watch("biddingType") === "other" && (
- <FormField
- control={form.control}
- name="biddingTypeCustom"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 기타 입찰유형 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="직접 입력하세요"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- </div>
-
- <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="contractStartDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약 시작일 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약 종료일 */}
- <FormField
- control={form.control}
- name="contractEndDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약 종료일 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* 통화 선택만 유지 */}
- <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>
- )}
- />
-
- {/* 입찰 조건 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle>입찰 조건</CardTitle>
- <p className="text-sm text-muted-foreground">
- 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요
- </p>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-2 gap-6">
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 지급조건 <span className="text-red-500">*</span>
- </label>
- <Select
- value={biddingConditions.paymentTerms}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- paymentTerms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="지급조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 세금조건 <span className="text-red-500">*</span>
- </label>
- <Select
- value={biddingConditions.taxConditions}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- taxConditions: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="세금조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 운송조건(인코텀즈) <span className="text-red-500">*</span>
- </label>
- <Select
- value={biddingConditions.incoterms}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- incoterms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 계약 납품일 <span className="text-red-500">*</span>
- </label>
- <Input
- type="date"
- value={biddingConditions.contractDeliveryDate}
- onChange={(e) => setBiddingConditions(prev => ({
- ...prev,
- contractDeliveryDate: e.target.value
- }))}
- />
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">선적지 (선택사항)</label>
- <Select
- value={biddingConditions.shippingPort}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- shippingPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="선적지 선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">하역지 (선택사항)</label>
- <Select
- value={biddingConditions.destinationPort}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- destinationPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="하역지 선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <div className="flex items-center space-x-2">
- <Switch
- id="price-adjustment"
- checked={biddingConditions.isPriceAdjustmentApplicable}
- onCheckedChange={(checked) => setBiddingConditions(prev => ({
- ...prev,
- isPriceAdjustmentApplicable: checked
- }))}
- />
- <label htmlFor="price-adjustment" className="text-sm font-medium">
- 연동제 적용 요건 문의
- </label>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">스페어파트 옵션</label>
- <Textarea
- placeholder="스페어파트 관련 옵션을 입력하세요"
- value={biddingConditions.sparePartOptions}
- onChange={(e) => setBiddingConditions(prev => ({
- ...prev,
- sparePartOptions: e.target.value
- }))}
- rows={3}
- />
- </div>
- </CardContent>
- </Card>
- </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="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>
- <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>
- </label>
- <Input
- type="datetime-local"
- value={specMeetingInfo.meetingDate}
- onChange={(e) => setSpecMeetingInfo(prev => ({ ...prev, meetingDate: e.target.value }))}
- className={!specMeetingInfo.meetingDate ? 'border-red-200' : ''}
- />
- {!specMeetingInfo.meetingDate && (
- <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 }))}
- />
- </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 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 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>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 긴급 입찰 설정 */}
- <Card>
- <CardHeader>
- <CardTitle>긴급 입찰 설정</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="isUrgent"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
- <div className="space-y-0.5">
- <FormLabel className="text-base">
- 긴급 입찰
- </FormLabel>
- <FormDescription>
- 긴급 입찰 여부를 설정합니다
- </FormDescription>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- </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">
- 최소 하나의 품목을 입력해야 합니다
- </p>
- <p className="text-xs text-amber-600 mt-1">
- 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
- </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-[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"
- />
- </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"
- min="0"
- 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="number"
- min="0"
- placeholder="중량"
- value={item.totalWeight}
- onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Select
- value={item.weightUnit}
- onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
- >
- <SelectTrigger className="h-8">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="KG">KG</SelectItem>
- <SelectItem value="TON">TON</SelectItem>
- <SelectItem value="G">G</SelectItem>
- <SelectItem value="LB">LB</SelectItem>
- </SelectContent>
- </Select>
- </TableCell>
- <TableCell>
- <Input
- type="date"
- value={item.requestedDeliveryDate}
- onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
- className="h-8"
- placeholder="납품요청일"
- />
- </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="outline"
- size="sm"
- onClick={() => removePRItem(item.id)}
- disabled={prItems.length <= 1}
- className="h-8 w-8 p-0"
- title={prItems.length <= 1 ? "최소 하나의 품목이 필요합니다" : "품목 삭제"}
- >
- <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>
- </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"
- >
- <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>
- )}
- </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>
- )}
- />
-
- <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="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>
- </div>
- </div>
- </CardContent>
- </Card> */}
- </TabsContent>
-
- </div>
- </Tabs>
+ {tab === 'basic' && '기본 정보'}
+ {tab === 'schedule' && '입찰 계획'}
+ {tab === 'details' && '세부 내역'}
+ {tab === 'manager' && '담당자'}
+ {!tabValidation[tab].isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </button>
+ ))}
+ </div>
+ </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="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>
+ )}
+ />
+ <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>
+ )}
+ />
+ {form.watch('biddingType') === 'other' && (
+ <FormField
+ control={form.control}
+ name="biddingTypeCustom"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="직접 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ </div>
+ <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="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>
+ <div className="grid grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="contractStartDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약시작일</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="contractEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약종료일</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="purchasingOrganization"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매조직</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매조직 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </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>
+ <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>
+ <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>회의일시 <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' : ''}
+ />
+ {!specMeetingInfo.meetingDate && (
+ <p className="text-sm text-red-500 mt-1">회의일시는 필수입니다</p>
+ )}
+ </div>
+ <div>
+ <Label>회의시간</Label>
+ <Input
+ placeholder="예: 14:00 ~ 16:00"
+ value={specMeetingInfo.meetingTime}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))}
+ />
+ </div>
+ </div>
+ <div>
+ <Label>장소 <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 className="grid grid-cols-3 gap-4">
+ <div>
+ <Label>담당자 <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>연락처</Label>
+ <Input
+ placeholder="전화번호"
+ value={specMeetingInfo.contactPhone}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))}
+ />
+ </div>
+ <div>
+ <Label>이메일</Label>
+ <Input
+ type="email"
+ placeholder="이메일"
+ value={specMeetingInfo.contactEmail}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))}
+ />
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 입찰 조건 섹션 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>입찰 조건</CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label>
+ 지급조건 <span className="text-red-500">*</span>
+ </Label>
+ <Select
+ value={biddingConditions.paymentTerms}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ paymentTerms: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="지급조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</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 === "conditions" && (
- <span>
- 입찰 조건을 설정하세요
- {!tabValidation.conditions.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "details" && (
- <span>
- 최소 하나의 품목을 입력하세요
- {!tabValidation.details.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"}
- </div>
-
- <div className="flex gap-3">
- <Button
- type="button"
- variant="outline"
- onClick={() => setShowCloseConfirmDialog(true)}
- disabled={isSubmitting}
- >
- 취소
- </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 className="space-y-2">
+ <Label>
+ 세금조건 <span className="text-red-500">*</span>
+ </Label>
+ <Select
+ value={biddingConditions.taxConditions}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ taxConditions: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="세금조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {TAX_CONDITIONS.map((condition) => (
+ <SelectItem key={condition.code} value={condition.code}>
+ {condition.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
</div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
-
- {/* 닫기 확인 다이얼로그 */}
- <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>입찰 생성을 취소하시겠습니까?</AlertDialogTitle>
- <AlertDialogDescription>
- 현재 입력 중인 내용이 모두 삭제되며, 생성되지 않습니다.
- 정말로 취소하시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel onClick={() => handleCloseConfirm(false)}>
- 아니오 (계속 입력)
- </AlertDialogCancel>
- <AlertDialogAction onClick={() => handleCloseConfirm(true)}>
- 예 (취소)
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
-
- <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>입찰이 성공적으로 생성되었습니다</AlertDialogTitle>
- <AlertDialogDescription>
- 생성된 입찰의 상세페이지로 이동하시겠습니까?
- 아니면 현재 페이지에 남아있으시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel onClick={handleStayOnPage}>
- 현재 페이지에 남기
- </AlertDialogCancel>
- <AlertDialogAction onClick={handleNavigateToDetail}>
- 상세페이지로 이동
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
+
+ <div className="space-y-2">
+ <Label>
+ 운송조건(인코텀즈) <span className="text-red-500">*</span>
+ </Label>
+ <Select
+ value={biddingConditions.incoterms}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ incoterms: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>인코텀즈 옵션 (선택사항)</Label>
+ <Input
+ placeholder="예: 현지 배송 포함, 특정 주소 배송 등"
+ value={biddingConditions.incotermsOption}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ incotermsOption: e.target.value
+ }))}
+ />
+ <p className="text-xs text-muted-foreground">
+ 인코텀즈와 관련된 추가 조건이나 특이사항을 입력하세요
+ </p>
+ </div>
+
+ <div className="space-y-2">
+ <Label>
+ 계약 납품일 <span className="text-red-500">*</span>
+ </Label>
+ <Input
+ type="date"
+ value={biddingConditions.contractDeliveryDate}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label>선적지 (선택사항)</Label>
+ <Select
+ value={biddingConditions.shippingPort}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ shippingPort: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선적지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>하역지 (선택사항)</Label>
+ <Select
+ value={biddingConditions.destinationPort}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ destinationPort: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="하역지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="price-adjustment"
+ checked={biddingConditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => setBiddingConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))}
+ />
+ <Label htmlFor="price-adjustment">
+ 연동제 적용 요건 문의
+ </Label>
+ </div>
+
+ <div className="space-y-2">
+ <Label>스페어파트 옵션</Label>
+ <Textarea
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={biddingConditions.sparePartOptions}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))}
+ rows={3}
+ />
+ </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">
+ 최소 하나의 아이템이 필요하며, 자재그룹코드는 필수입니다
+ </p>
+ <p className="text-xs text-amber-600 mt-1">
+ 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
+ </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">
+ <div className="flex items-center space-x-4 p-4 bg-muted rounded-lg">
+ <div className="text-sm font-medium">계산 기준:</div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="quantity-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'quantity'}
+ onChange={() => handleQuantityWeightModeChange('quantity')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="quantity-mode" className="text-sm">수량 기준</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="weight-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'weight'}
+ onChange={() => handleQuantityWeightModeChange('weight')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="weight-mode" className="text-sm">중량 기준</label>
+ </div>
+ </div>
+ <div className="space-y-4">
+ {prItems.length > 0 ? (
+ renderPrItemsTable()
+ ) : (
+ <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>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="manager" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>담당자 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label>입찰담당자 <span className="text-red-500">*</span></Label>
+ <PurchaseGroupCodeSelector
+ onCodeSelect={(code) => {
+ form.setValue('managerName', code.DISPLAY_NAME || '')
+ }}
+ placeholder="입찰담당자 선택"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label>조달담당자 <span className="text-red-500">*</span></Label>
+ <ProcurementManagerSelector
+ onManagerSelect={(manager) => {
+ form.setValue('managerEmail', manager.DISPLAY_NAME || '')
+ }}
+ placeholder="조달담당자 선택"
+ />
+ </div>
+ </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>
+ </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>기본 정보를 입력하세요.</span>)}
+ {activeTab === 'schedule' && (<span>일정 및 사양설명회 정보를 입력하세요.</span>)}
+ {activeTab === 'details' && (<span>세부내역을 관리하세요.</span>)}
+ {activeTab === 'manager' && (<span>담당자 정보를 확인하고 입찰을 생성하세요.</span>)}
+ {!tabValidation[activeTab].isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </div>
+ <div className="flex gap-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setShowCloseConfirmDialog(true)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </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 ? (
+ <Button
+ type="button"
+ onClick={handleCreateBidding}
+ disabled={isSubmitting || !isCurrentTabValid()}
+ 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 || !isCurrentTabValid()}
+ className="flex items-center gap-2"
+ >
+ 다음
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ {/* 닫기 확인 다이얼로그 */}
+ <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>입찰 생성을 취소하시겠습니까?</AlertDialogTitle>
+ <AlertDialogDescription>
+ 현재 입력 중인 내용이 모두 삭제되며, 생성되지 않습니다. 정말로 취소하시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={() => handleCloseConfirm(false)}>
+ 아니오 (계속 입력)
+ </AlertDialogCancel>
+ <AlertDialogAction onClick={() => handleCloseConfirm(true)}>
+ 예 (취소)
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ {/* 성공 다이얼로그 */}
+ <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