diff options
Diffstat (limited to 'lib/bidding/list')
| -rw-r--r-- | lib/bidding/list/bidding-detail-dialogs.tsx | 554 | ||||
| -rw-r--r-- | lib/bidding/list/bidding-pr-documents-dialog.tsx | 405 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-columns.tsx | 649 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-toolbar-actions.tsx | 64 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table.tsx | 30 | ||||
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 4230 |
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 |
