summaryrefslogtreecommitdiff
path: root/lib/bidding/list
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bidding/list')
-rw-r--r--lib/bidding/list/biddings-page-header.tsx41
-rw-r--r--lib/bidding/list/biddings-stats-cards.tsx122
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx578
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx143
-rw-r--r--lib/bidding/list/biddings-table.tsx135
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx1674
-rw-r--r--lib/bidding/list/edit-bidding-sheet.tsx505
7 files changed, 3198 insertions, 0 deletions
diff --git a/lib/bidding/list/biddings-page-header.tsx b/lib/bidding/list/biddings-page-header.tsx
new file mode 100644
index 00000000..ece29e07
--- /dev/null
+++ b/lib/bidding/list/biddings-page-header.tsx
@@ -0,0 +1,41 @@
+"use client"
+
+import { Button } from "@/components/ui/button"
+import { Plus, FileText, TrendingUp } from "lucide-react"
+import { useRouter } from "next/navigation"
+
+export function BiddingsPageHeader() {
+ const router = useRouter()
+
+ return (
+ <div className="flex items-center justify-between">
+ {/* 좌측: 제목과 설명 */}
+ <div className="space-y-1">
+ <h1 className="text-3xl font-bold tracking-tight">입찰 목록 관리</h1>
+ <p className="text-muted-foreground">
+ 입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다.
+ </p>
+ </div>
+
+ {/* 우측: 액션 버튼들 */}
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ onClick={() => router.push('/evcp/biddings/analytics')}
+ >
+ <TrendingUp className="mr-2 h-4 w-4" />
+ 분석 보기
+ </Button>
+
+ <Button
+ variant="outline"
+ onClick={() => router.push('/evcp/bidding-notice')}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ 공고문 템플릿
+ </Button>
+
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/bidding/list/biddings-stats-cards.tsx b/lib/bidding/list/biddings-stats-cards.tsx
new file mode 100644
index 00000000..2926adac
--- /dev/null
+++ b/lib/bidding/list/biddings-stats-cards.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import {
+ FileText,
+ Clock,
+ CheckCircle,
+ TrendingUp,
+ Users,
+ DollarSign,
+ Calendar,
+ BarChart3
+} from "lucide-react"
+import { biddingStatusLabels, biddingTypeLabels } from "@/db/schema"
+
+interface BiddingsStatsCardsProps {
+ total: number
+ statusCounts: Record<string, number>
+ typeCounts: Record<string, number>
+ managerCounts: Record<string, number>
+ monthlyStats: Array<{ month: number; count: number }>
+}
+
+export function BiddingsStatsCards({
+ total,
+ statusCounts,
+ typeCounts,
+ managerCounts,
+ monthlyStats
+}: BiddingsStatsCardsProps) {
+ // 이번 달 생성 건수
+ const currentMonth = new Date().getMonth() + 1
+ const thisMonthCount = monthlyStats.find(stat => stat.month === currentMonth)?.count || 0
+
+ // 지난 달 대비 증감
+ const lastMonthCount = monthlyStats.find(stat => stat.month === currentMonth - 1)?.count || 0
+ const monthlyGrowth = lastMonthCount > 0 ? ((thisMonthCount - lastMonthCount) / lastMonthCount * 100).toFixed(1) : '0'
+
+ // 진행중인 입찰 수 (active 상태들)
+ const activeStatuses = ['bidding_opened', 'bidding_closed', 'evaluation_of_bidding']
+ const activeBiddingsCount = activeStatuses.reduce((sum, status) => sum + (statusCounts[status] || 0), 0)
+
+ // 완료된 입찰 수
+ const completedCount = statusCounts['vendor_selected'] || 0
+
+ // 가장 많은 담당자
+ const topManager = Object.entries(managerCounts).sort(([,a], [,b]) => b - a)[0]
+
+ // 가장 많은 입찰 유형
+ const topBiddingType = Object.entries(typeCounts).sort(([,a], [,b]) => b - a)[0]
+
+ return (
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
+ {/* 전체 입찰 수 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">전체 입찰</CardTitle>
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">{total.toLocaleString()}</div>
+ <p className="text-xs text-muted-foreground">
+ 이번 달 <span className="font-medium text-green-600">+{thisMonthCount}</span>건
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 진행중인 입찰 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">진행중</CardTitle>
+ <Clock className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-orange-600">{activeBiddingsCount}</div>
+ <div className="flex gap-1 mt-1">
+ {activeStatuses.map(status => (
+ statusCounts[status] > 0 && (
+ <Badge key={status} variant="outline" className="text-xs">
+ {biddingStatusLabels[status]}: {statusCounts[status]}
+ </Badge>
+ )
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 완료된 입찰 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">완료</CardTitle>
+ <CheckCircle className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold text-green-600">{completedCount}</div>
+ <p className="text-xs text-muted-foreground">
+ 완료율 {total > 0 ? ((completedCount / total) * 100).toFixed(1) : 0}%
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 월별 증감 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
+ <CardTitle className="text-sm font-medium">월별 증감</CardTitle>
+ <TrendingUp className="h-4 w-4 text-muted-foreground" />
+ </CardHeader>
+ <CardContent>
+ <div className="text-2xl font-bold">
+ <span className={Number(monthlyGrowth) >= 0 ? 'text-green-600' : 'text-red-600'}>
+ {Number(monthlyGrowth) >= 0 ? '+' : ''}{monthlyGrowth}%
+ </span>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ 지난 달 대비
+ </p>
+ </CardContent>
+ </Card>
+ </div>
+ )
+}
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
new file mode 100644
index 00000000..34fc574e
--- /dev/null
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -0,0 +1,578 @@
+"use client"
+
+import * as React from "react"
+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 {
+ Eye, Edit, MoreHorizontal, FileText, Users, Calendar,
+ Building, Package, DollarSign, Clock, CheckCircle, XCircle
+} from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { BiddingListItem } from "@/db/schema"
+import { DataTableRowAction } from "@/types/table"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+ biddingTypeLabels,
+ awardCountLabels
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingListItem> | null>>
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'bidding_generated':
+ return 'outline'
+ case 'request_for_quotation':
+ case 'received_quotation':
+ return 'secondary'
+ case 'set_target_price':
+ case 'bidding_opened':
+ return 'default'
+ case 'bidding_closed':
+ case 'evaluation_of_bidding':
+ return 'default'
+ case 'vendor_selected':
+ return 'default'
+ case 'bidding_disposal':
+ return 'destructive'
+ default:
+ return 'outline'
+ }
+}
+
+// 금액 포맷팅
+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<BiddingListItem>[] {
+ return [
+ // ═══════════════════════════════════════════════════════════════
+ // 선택 및 기본 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // ░░░ 입찰 No. ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰 No." />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ {row.original.revision > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">
+ Rev.{row.original.revision}
+ </span>
+ )}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "입찰 No." },
+ },
+
+ // ░░░ 입찰상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {biddingStatusLabels[row.original.status]}
+ </Badge>
+ ),
+ size: 120,
+ 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: "managerName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[100px]" title={row.original.managerName || ''}>
+ {row.original.managerName || '-'}
+ </div>
+ ),
+ 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"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ {row.original.title}
+ </Button>
+ </div>
+ ),
+ size: 200,
+ 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: "낙찰수" },
+ },
+
+ {
+ accessorKey: "contractPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.contractPeriod || '-'}</span>
+ ),
+ size: 100,
+ 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: "사양설명회" },
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 가격 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ 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">
+ {formatCurrency(row.original.budget, row.original.currency)}
+ </span>
+ ),
+ size: 120,
+ meta: { excelHeader: "예산" },
+ },
+
+ {
+ accessorKey: "targetPrice",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium text-orange-600">
+ {formatCurrency(row.original.targetPrice, row.original.currency)}
+ </span>
+ ),
+ size: 120,
+ meta: { excelHeader: "내정가" },
+ },
+
+ {
+ accessorKey: "finalBidPrice",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종입찰가" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium text-green-600">
+ {formatCurrency(row.original.finalBidPrice, row.original.currency)}
+ </span>
+ ),
+ size: 120,
+ meta: { excelHeader: "최종입찰가" },
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 참여 현황
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "참여 현황",
+ columns: [
+ {
+ id: "participantExpected",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정" />,
+ cell: ({ row }) => (
+ <Badge variant="outline" className="font-mono">
+ {row.original.participantStats.expected}
+ </Badge>
+ ),
+ size: 80,
+ meta: { excelHeader: "참여예정" },
+ },
+
+ {
+ id: "participantParticipated",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여" />,
+ cell: ({ row }) => (
+ <Badge variant="default" className="font-mono">
+ {row.original.participantStats.participated}
+ </Badge>
+ ),
+ size: 60,
+ meta: { excelHeader: "참여" },
+ },
+
+ {
+ id: "participantDeclined",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기" />,
+ cell: ({ row }) => (
+ <Badge variant="destructive" className="font-mono">
+ {row.original.participantStats.declined}
+ </Badge>
+ ),
+ size: 60,
+ 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
+
+ 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: "최종수정자" },
+ },
+ ]
+ },
+
+ // ░░░ 비고 ░░░
+ {
+ accessorKey: "remarks",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[150px]" title={row.original.remarks || ''}>
+ {row.original.remarks || '-'}
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "비고" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 액션
+ // ═══════════════════════════════════════════════════════════════
+ {
+ id: "actions",
+ header: "작업",
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "edit" })}>
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "copy" })}>
+ <Package className="mr-2 h-4 w-4" />
+ 복사 생성
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "manage_companies" })}>
+ <Users className="mr-2 h-4 w-4" />
+ 참여업체 관리
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
new file mode 100644
index 00000000..81982a43
--- /dev/null
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ Plus, Send, Gavel, Download, FileSpreadsheet,
+ Eye, Clock, CheckCircle
+} from "lucide-react"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { BiddingListItem } from "@/db/schema"
+import { CreateBiddingDialog } from "./create-bidding-dialog"
+
+interface BiddingsTableToolbarActionsProps {
+ table: Table<BiddingListItem>
+}
+
+export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActionsProps) {
+ const router = useRouter()
+ const [isExporting, setIsExporting] = React.useState(false)
+
+ // 선택된 입찰들
+ const selectedBiddings = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ }, [table.getFilteredSelectedRowModel().rows])
+
+ // 사전견적 요청 가능한 입찰들 (입찰생성 상태)
+ const preQuoteEligibleBiddings = React.useMemo(() => {
+ return selectedBiddings.filter(bidding =>
+ bidding.status === 'bidding_generated'
+ )
+ }, [selectedBiddings])
+
+ // 개찰 가능한 입찰들 (내정가 산정 완료)
+ const openEligibleBiddings = React.useMemo(() => {
+ return selectedBiddings.filter(bidding =>
+ bidding.status === 'set_target_price'
+ )
+ }, [selectedBiddings])
+
+
+ const handlePreQuoteRequest = () => {
+ if (preQuoteEligibleBiddings.length === 0) {
+ toast.warning("사전견적 요청 가능한 입찰을 선택해주세요.")
+ return
+ }
+
+ toast.success(`${preQuoteEligibleBiddings.length}개 입찰의 사전견적을 요청했습니다.`)
+ // TODO: 실제 사전견적 요청 로직 구현
+ }
+
+ const handleBiddingOpen = () => {
+ if (openEligibleBiddings.length === 0) {
+ toast.warning("개찰 가능한 입찰을 선택해주세요.")
+ return
+ }
+
+ toast.success(`${openEligibleBiddings.length}개 입찰을 개찰했습니다.`)
+ // TODO: 실제 개찰 로직 구현
+ }
+
+ const handleExport = async () => {
+ try {
+ setIsExporting(true)
+ await exportTableToExcel(table, {
+ filename: "biddings",
+ excludeColumns: ["select", "actions"],
+ })
+ toast.success("입찰 목록이 성공적으로 내보내졌습니다.")
+ } catch (error) {
+ toast.error("내보내기 중 오류가 발생했습니다.")
+ } finally {
+ setIsExporting(false)
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 신규 생성 */}
+ <CreateBiddingDialog/>
+
+ {/* 사전견적 요청 */}
+ {preQuoteEligibleBiddings.length > 0 && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handlePreQuoteRequest}
+ >
+ <Send className="mr-2 h-4 w-4" />
+ 사전견적 요청 ({preQuoteEligibleBiddings.length})
+ </Button>
+ )}
+
+ {/* 개찰 (입찰 오픈) */}
+ {openEligibleBiddings.length > 0 && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleBiddingOpen}
+ >
+ <Gavel className="mr-2 h-4 w-4" />
+ 개찰 ({openEligibleBiddings.length})
+ </Button>
+ )}
+
+ {/* Export */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ disabled={isExporting}
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isExporting ? "내보내는 중..." : "Export"}
+ </span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={handleExport} disabled={isExporting}>
+ <FileSpreadsheet className="mr-2 size-4" />
+ <span>입찰 목록 내보내기</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
new file mode 100644
index 00000000..ce4aade9
--- /dev/null
+++ b/lib/bidding/list/biddings-table.tsx
@@ -0,0 +1,135 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getBiddingsColumns } from "./biddings-table-columns"
+import { getBiddings, getBiddingStatusCounts } from "@/lib/bidding/service"
+import { BiddingListItem } from "@/db/schema"
+import { BiddingsTableToolbarActions } from "./biddings-table-toolbar-actions"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+ biddingTypeLabels
+} from "@/db/schema"
+import { EditBiddingSheet } from "./edit-bidding-sheet"
+
+interface BiddingsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBiddings>>,
+ Awaited<ReturnType<typeof getBiddingStatusCounts>>
+ ]
+ >
+}
+
+export function BiddingsTable({ promises }: BiddingsTableProps) {
+ const [{ data, pageCount }, statusCounts] = React.use(promises)
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingListItem> | null>(null)
+
+ const router = useRouter()
+
+ const columns = React.useMemo(
+ () => getBiddingsColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ const filterFields: DataTableFilterField<BiddingListItem>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<BiddingListItem>[] = [
+ { id: "title", label: "입찰명", type: "text" },
+ { id: "biddingNumber", label: "입찰번호", type: "text" },
+ { id: "projectName", label: "프로젝트명", type: "text" },
+ { id: "managerName", label: "담당자", type: "text" },
+ {
+ id: "status",
+ label: "입찰상태",
+ type: "multi-select",
+ options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ count: statusCounts[value] || 0,
+ })),
+ },
+ {
+ id: "contractType",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "biddingType",
+ label: "입찰유형",
+ type: "select",
+ options: Object.entries(biddingTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "updatedAt", label: "수정일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <BiddingsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <EditBiddingSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ bidding={rowAction?.row.original}
+ onSuccess={() => router.refresh()}
+ />
+ </>
+
+ )
+} \ No newline at end of file
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
new file mode 100644
index 00000000..683f6aff
--- /dev/null
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -0,0 +1,1674 @@
+"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 {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+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 {
+ createBiddingSchema,
+ type CreateBiddingSchema
+} from "@/lib/bidding/validation"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+ biddingTypeLabels,
+ awardCountLabels
+} from "@/db/schema"
+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[] // 사양설명회 첨부파일
+}
+
+// PR 아이템 정보 타입
+interface PRItemInfo {
+ id: string // 임시 ID for UI
+ prNumber: string
+ itemCode: string // 기존 itemNumber에서 변경
+ itemInfo: string
+ quantity: string
+ quantityUnit: string
+ requestedDeliveryDate: string
+ specFiles: File[]
+ isRepresentative: boolean // 대표 아이템 여부
+}
+
+// 탭 순서 정의
+const TAB_ORDER = ["basic", "contract", "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 [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
+ const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
+
+ // 사양설명회 파일 추가
+ const addMeetingFiles = (files: File[]) => {
+ setSpecMeetingInfo(prev => ({
+ ...prev,
+ meetingFiles: [...prev.meetingFiles, ...files]
+ }))
+ }
+
+ // 사양설명회 파일 제거
+ const removeMeetingFile = (fileIndex: number) => {
+ setSpecMeetingInfo(prev => ({
+ ...prev,
+ meetingFiles: prev.meetingFiles.filter((_, index) => index !== fileIndex)
+ }))
+ }
+
+ // 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",
+ awardCount: "single",
+ contractPeriod: "",
+
+ 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 goToPreviousTab = () => {
+ if (!isFirstTab) {
+ setActiveTab(TAB_ORDER[currentTabIndex - 1])
+ }
+ }
+
+ // 탭별 validation 상태 체크
+ const getTabValidationState = React.useCallback(() => {
+ const formValues = form.getValues()
+ const formErrors = form.formState.errors
+
+ return {
+ basic: {
+ isValid: formValues.projectId > 0 &&
+ formValues.itemName.trim() !== "" &&
+ formValues.title.trim() !== "",
+ hasErrors: !!(formErrors.projectId || formErrors.itemName || formErrors.title)
+ },
+ contract: {
+ isValid: formValues.contractType &&
+ formValues.biddingType &&
+ formValues.awardCount &&
+ formValues.contractPeriod.trim() !== "" &&
+ formValues.currency,
+ hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractPeriod || formErrors.currency)
+ },
+ schedule: {
+ isValid: formValues.submissionStartDate &&
+ formValues.submissionEndDate &&
+ (!formValues.hasSpecificationMeeting ||
+ (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)),
+ hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate)
+ },
+ details: {
+ isValid: true, // 세부내역은 선택사항
+ hasErrors: false
+ },
+ manager: {
+ isValid: true, // 담당자 정보는 자동 설정되므로 항상 유효
+ hasErrors: !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone)
+ }
+ }
+ }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson])
+
+ const tabValidation = getTabValidationState()
+
+ // 현재 탭이 유효한지 확인
+ const isCurrentTabValid = () => {
+ const validation = tabValidation[activeTab as keyof typeof tabValidation]
+ return validation?.isValid ?? true
+ }
+
+ // 대표 PR 번호 자동 계산
+ const representativePrNumber = React.useMemo(() => {
+ const representativeItem = prItems.find(item => item.isRepresentative)
+ return representativeItem?.prNumber || ""
+ }, [prItems])
+
+ // hasPrDocument 필드와 prNumber를 자동으로 업데이트
+ React.useEffect(() => {
+ form.setValue("hasPrDocument", hasPrDocuments)
+ form.setValue("prNumber", representativePrNumber)
+ }, [hasPrDocuments, representativePrNumber, 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 (session.user.phone) {
+ form.setValue("managerPhone", session.user.phone)
+ }
+ }
+ }, [session, form])
+
+ // PR 아이템 추가
+ const addPRItem = () => {
+ const newItem: PRItemInfo = {
+ id: `pr-${Date.now()}`,
+ prNumber: "",
+ itemCode: "",
+ itemInfo: "",
+ quantity: "",
+ quantityUnit: "EA",
+ requestedDeliveryDate: "",
+ specFiles: [],
+ isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템
+ }
+ setPrItems(prev => [...prev, newItem])
+ }
+
+ // PR 아이템 제거
+ const removePRItem = (id: string) => {
+ 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)
+ }
+ }
+
+ // PR 아이템 업데이트
+ const updatePRItem = (id: string, updates: Partial<PRItemInfo>) => {
+ setPrItems(prev => prev.map(item =>
+ item.id === id ? { ...item, ...updates } : item
+ ))
+ }
+
+ // 대표 아이템 설정 (하나만 선택 가능)
+ const setRepresentativeItem = (id: string) => {
+ setPrItems(prev => prev.map(item => ({
+ ...item,
+ isRepresentative: item.id === id
+ })))
+ }
+
+ // 스펙 파일 추가
+ 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 })
+ }
+ }
+
+ // ✅ 프로젝트 선택 핸들러
+ const handleProjectSelect = React.useCallback((project: { id: number; code: string; name: string } | null) => {
+ if (project) {
+ form.setValue("projectId", project.id)
+ form.setValue("projectName", `${project.code} (${project.name})`)
+ } else {
+ form.setValue("projectId", 0)
+ form.setValue("projectName", "")
+ }
+ }, [form])
+
+ // 다음 버튼 클릭 핸들러
+ const handleNextClick = () => {
+ // 현재 탭 validation 체크
+ 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("제출 시작일시와 마감일시를 입력해주세요")
+ }
+ }
+ return
+ }
+
+ 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
+ }
+ }
+
+ 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 : [],
+ }
+
+ const result = await createBidding(extendedData, userId)
+
+ if (result.success) {
+ toast.success(result.message)
+ setOpen(false)
+ router.refresh()
+
+ // 생성된 입찰 상세페이지로 이동할지 묻기
+ if (result.data?.id) {
+ setTimeout(() => {
+ if (confirm("생성된 입찰의 상세페이지로 이동하시겠습니까?")) {
+ router.push(`/admin/biddings/${result.data.id}`)
+ }
+ }, 500)
+ }
+ } else {
+ toast.error(result.error || "입찰 생성에 실패했습니다.")
+ }
+ } 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",
+ awardCount: "single",
+ contractPeriod: "",
+ submissionStartDate: "",
+ submissionEndDate: "",
+ hasSpecificationMeeting: false,
+ prNumber: "",
+ currency: "KRW",
+ budget: "",
+ targetPrice: "",
+ finalBidPrice: "",
+ status: "bidding_generated",
+ isPublic: false,
+ managerName: "",
+ managerEmail: "",
+ managerPhone: "",
+ remarks: "",
+ })
+
+ // 추가 상태들 초기화
+ setSpecMeetingInfo({
+ meetingDate: "",
+ meetingTime: "",
+ location: "",
+ address: "",
+ contactPerson: "",
+ contactPhone: "",
+ contactEmail: "",
+ agenda: "",
+ materials: "",
+ notes: "",
+ isRequired: false,
+ meetingFiles: [],
+ })
+ setPrItems([])
+ setSelectedItemForFile(null)
+ setActiveTab("basic")
+ }, [form])
+
+ // 다이얼로그 핸들러
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ resetAllStates()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ 신규 입찰
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-6xl h-[90vh] p-0 flex flex-col">
+ {/* 고정 헤더 */}
+ <div className="flex-shrink-0 p-6 border-b">
+ <DialogHeader>
+ <DialogTitle>신규 입찰 생성</DialogTitle>
+ <DialogDescription>
+ 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col flex-1 min-h-0"
+ id="create-bidding-form"
+ >
+ {/* 탭 영역 */}
+ <div className="flex-1 overflow-hidden">
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
+ <div className="px-6 pt-4">
+ <TabsList className="grid w-full grid-cols-5">
+ <TabsTrigger value="basic" className="relative">
+ 기본 정보
+ {!tabValidation.basic.isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="contract" className="relative">
+ 계약 정보
+ {!tabValidation.contract.isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="schedule" className="relative">
+ 일정 & 회의
+ {!tabValidation.schedule.isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </TabsTrigger>
+ <TabsTrigger value="details">세부내역</TabsTrigger>
+ <TabsTrigger value="manager">담당자 & 기타</TabsTrigger>
+ </TabsList>
+ </div>
+
+ <div className="flex-1 overflow-y-auto p-6">
+ {/* 기본 정보 탭 */}
+ <TabsContent value="basic" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 프로젝트 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <ProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={handleProjectSelect}
+ placeholder="프로젝트 선택..."
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <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>
+ )}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 계약 정보 탭 */}
+ <TabsContent value="contract" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>계약 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ {/* 계약구분 */}
+ <FormField
+ control={form.control}
+ name="contractType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 계약구분 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(contractTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 입찰유형 */}
+ <FormField
+ control={form.control}
+ name="biddingType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 입찰유형 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입찰유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-6">
+ {/* 낙찰수 */}
+ <FormField
+ control={form.control}
+ name="awardCount"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 낙찰수 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="낙찰수 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(awardCountLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약기간 */}
+ <FormField
+ control={form.control}
+ name="contractPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 계약기간 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 계약일로부터 60일"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ <Card>
+ <CardHeader>
+ <CardTitle>가격 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 통화 */}
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 통화 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="KRW">KRW (원)</SelectItem>
+ <SelectItem value="USD">USD (달러)</SelectItem>
+ <SelectItem value="EUR">EUR (유로)</SelectItem>
+ <SelectItem value="JPY">JPY (엔)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-3 gap-6">
+ {/* 예산 */}
+ <FormField
+ control={form.control}
+ name="budget"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>예산</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.01"
+ placeholder="0"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 내정가 */}
+ <FormField
+ control={form.control}
+ name="targetPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>내정가</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.01"
+ placeholder="0"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 최종입찰가 */}
+ <FormField
+ control={form.control}
+ name="finalBidPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>최종입찰가</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.01"
+ placeholder="0"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 일정 & 회의 탭 */}
+ <TabsContent value="schedule" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>일정 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ {/* 제출시작일시 */}
+ <FormField
+ control={form.control}
+ name="submissionStartDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 제출시작일시 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="datetime-local"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 제출마감일시 */}
+ <FormField
+ control={form.control}
+ name="submissionEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 제출마감일시 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="datetime-local"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 사양설명회 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>사양설명회</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <FormField
+ control={form.control}
+ name="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>
+ </TabsContent>
+
+ {/* 세부내역 탭 */}
+ <TabsContent value="details" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle>세부내역 관리</CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ PR 아이템 또는 수기 아이템을 추가하여 입찰 세부내역을 관리하세요
+ </p>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={addPRItem}
+ className="flex items-center gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 아이템 추가
+ </Button>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 아이템 테이블 */}
+ {prItems.length > 0 ? (
+ <div className="space-y-4">
+ <div className="border rounded-lg">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[60px]">대표</TableHead>
+ <TableHead className="w-[120px]">PR 번호</TableHead>
+ <TableHead className="w-[120px]">품목코드</TableHead>
+ <TableHead>품목정보</TableHead>
+ <TableHead className="w-[80px]">수량</TableHead>
+ <TableHead className="w-[80px]">단위</TableHead>
+ <TableHead className="w-[140px]">납품요청일</TableHead>
+ <TableHead className="w-[80px]">스펙파일</TableHead>
+ <TableHead className="w-[80px]">액션</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {prItems.map((item, index) => (
+ <TableRow key={item.id}>
+ <TableCell>
+ <div className="flex justify-center">
+ <Checkbox
+ checked={item.isRepresentative}
+ onCheckedChange={() => setRepresentativeItem(item.id)}
+ />
+ </div>
+ </TableCell>
+ <TableCell>
+ <Input
+ placeholder="PR 번호"
+ value={item.prNumber}
+ onChange={(e) => updatePRItem(item.id, { prNumber: e.target.value })}
+ className="h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ placeholder={`ITEM-${index + 1}`}
+ value={item.itemCode}
+ onChange={(e) => updatePRItem(item.id, { itemCode: e.target.value })}
+ className="h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ placeholder="품목정보"
+ value={item.itemInfo}
+ onChange={(e) => updatePRItem(item.id, { itemInfo: e.target.value })}
+ className="h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Input
+ type="number"
+ placeholder="수량"
+ value={item.quantity}
+ onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
+ className="h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <Select
+ value={item.quantityUnit}
+ onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
+ >
+ <SelectTrigger className="h-8">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="EA">EA</SelectItem>
+ <SelectItem value="SET">SET</SelectItem>
+ <SelectItem value="LOT">LOT</SelectItem>
+ <SelectItem value="M">M</SelectItem>
+ <SelectItem value="M2">M²</SelectItem>
+ <SelectItem value="M3">M³</SelectItem>
+ </SelectContent>
+ </Select>
+ </TableCell>
+ <TableCell>
+ <Input
+ type="date"
+ value={item.requestedDeliveryDate}
+ onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
+ className="h-8"
+ />
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <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)}
+ className="h-8 w-8 p-0"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 대표 아이템 정보 표시 */}
+ {representativePrNumber && (
+ <div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
+ <CheckCircle2 className="h-4 w-4 text-blue-600" />
+ <span className="text-sm text-blue-800">
+ 대표 PR 번호: <strong>{representativePrNumber}</strong>
+ </span>
+ </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}>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListSize>{file.size}</FileListSize>
+ </FileListInfo>
+ <FileListAction>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => removeSpecFile(selectedItemForFile, fileIndex)}
+ >
+ 삭제
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ )}
+ </>
+ )
+ })()}
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg">
+ <FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <p className="text-gray-500 mb-2">아직 아이템이 없습니다</p>
+ <p className="text-sm text-gray-400 mb-4">
+ PR 아이템이나 수기 아이템을 추가하여 입찰 세부내역을 작성하세요
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={addPRItem}
+ className="flex items-center gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 첫 번째 아이템 추가
+ </Button>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 담당자 & 기타 탭 */}
+ <TabsContent value="manager" className="mt-0 space-y-6">
+ {/* 담당자 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>담당자 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <FormField
+ control={form.control}
+ name="managerName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자명</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="담당자명"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 현재 로그인한 사용자 정보로 자동 설정됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <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>
+ </div>
+
+ {/* 고정 버튼 영역 */}
+ <div className="flex-shrink-0 border-t bg-background p-6">
+ <div className="flex justify-between items-center">
+ <div className="text-sm text-muted-foreground">
+ {activeTab === "basic" && (
+ <span>
+ 기본 정보를 입력하세요
+ {!tabValidation.basic.isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </span>
+ )}
+ {activeTab === "contract" && (
+ <span>
+ 계약 및 가격 정보를 입력하세요
+ {!tabValidation.contract.isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </span>
+ )}
+ {activeTab === "schedule" && (
+ <span>
+ 일정 및 사양설명회 정보를 입력하세요
+ {!tabValidation.schedule.isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </span>
+ )}
+ {activeTab === "details" && "세부내역 아이템을 관리하세요 (선택사항)"}
+ {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"}
+ </div>
+
+ <div className="flex gap-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ resetAllStates()
+ setOpen(false)
+ }}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+
+ {/* 이전 버튼 (첫 번째 탭이 아닐 때) */}
+ {!isFirstTab && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={goToPreviousTab}
+ disabled={isSubmitting}
+ className="flex items-center gap-2"
+ >
+ <ChevronLeft className="h-4 w-4" />
+ 이전
+ </Button>
+ )}
+
+ {/* 다음/생성 버튼 */}
+ {isLastTab ? (
+ // 마지막 탭: 입찰 생성 버튼 (submit)
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ className="flex items-center gap-2"
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 입찰 생성
+ </Button>
+ ) : (
+ // 이전 탭들: 다음 버튼 (일반 버튼)
+ <Button
+ type="button"
+ onClick={handleNextClick}
+ disabled={isSubmitting}
+ className="flex items-center gap-2"
+ >
+ 다음
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/bidding/list/edit-bidding-sheet.tsx b/lib/bidding/list/edit-bidding-sheet.tsx
new file mode 100644
index 00000000..f3bc1805
--- /dev/null
+++ b/lib/bidding/list/edit-bidding-sheet.tsx
@@ -0,0 +1,505 @@
+"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 } from "lucide-react"
+import { toast } from "sonner"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+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 { updateBidding, type UpdateBiddingInput } from "@/lib/bidding/service"
+import {
+ updateBiddingSchema,
+ type UpdateBiddingSchema
+} from "@/lib/bidding/validation"
+import { BiddingListView } from "@/db/schema"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+ biddingTypeLabels,
+ awardCountLabels
+} from "@/db/schema"
+
+interface EditBiddingSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ bidding: BiddingListView | null
+ onSuccess?: () => void
+}
+
+export function EditBiddingSheet({
+ open,
+ onOpenChange,
+ bidding,
+ onSuccess
+}: EditBiddingSheetProps) {
+ const router = useRouter()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const { data: session } = useSession()
+
+ const form = useForm<UpdateBiddingSchema>({
+ resolver: zodResolver(updateBiddingSchema),
+ defaultValues: {
+ biddingNumber: "",
+ revision: 0,
+ projectName: "",
+ itemName: "",
+ title: "",
+ description: "",
+ content: "",
+
+ contractType: "general",
+ biddingType: "equipment",
+ awardCount: "single",
+ contractPeriod: "",
+
+ preQuoteDate: "",
+ biddingRegistrationDate: "",
+ submissionStartDate: "",
+ submissionEndDate: "",
+ evaluationDate: "",
+
+ hasSpecificationMeeting: false,
+ hasPrDocument: false,
+ prNumber: "",
+
+ currency: "KRW",
+ budget: "",
+ targetPrice: "",
+ finalBidPrice: "",
+
+ status: "bidding_generated",
+ isPublic: false,
+ managerName: "",
+ managerEmail: "",
+ managerPhone: "",
+
+ remarks: "",
+ },
+ })
+
+ // 시트가 열릴 때 기존 데이터로 폼 초기화
+ React.useEffect(() => {
+ if (open && bidding) {
+ const formatDateForInput = (date: Date | string | null): string => {
+ if (!date) return ""
+ try {
+ const d = new Date(date)
+ if (isNaN(d.getTime())) return ""
+ return d.toISOString().slice(0, 16) // YYYY-MM-DDTHH:mm
+ } catch {
+ return ""
+ }
+ }
+
+ const formatDateOnlyForInput = (date: Date | string | null): string => {
+ if (!date) return ""
+ try {
+ const d = new Date(date)
+ if (isNaN(d.getTime())) return ""
+ return d.toISOString().slice(0, 10) // YYYY-MM-DD
+ } catch {
+ return ""
+ }
+ }
+
+ form.reset({
+ biddingNumber: bidding.biddingNumber || "",
+ revision: bidding.revision || 0,
+ projectName: bidding.projectName || "",
+ itemName: bidding.itemName || "",
+ title: bidding.title || "",
+ description: bidding.description || "",
+ content: bidding.content || "",
+
+ contractType: bidding.contractType || "general",
+ biddingType: bidding.biddingType || "equipment",
+ awardCount: bidding.awardCount || "single",
+ contractPeriod: bidding.contractPeriod || "",
+
+ preQuoteDate: formatDateOnlyForInput(bidding.preQuoteDate),
+ biddingRegistrationDate: formatDateOnlyForInput(bidding.biddingRegistrationDate),
+ submissionStartDate: formatDateForInput(bidding.submissionStartDate),
+ submissionEndDate: formatDateForInput(bidding.submissionEndDate),
+ evaluationDate: formatDateForInput(bidding.evaluationDate),
+
+ hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
+ hasPrDocument: bidding.hasPrDocument || false,
+ prNumber: bidding.prNumber || "",
+
+ currency: bidding.currency || "KRW",
+ budget: bidding.budget?.toString() || "",
+ targetPrice: bidding.targetPrice?.toString() || "",
+ finalBidPrice: bidding.finalBidPrice?.toString() || "",
+
+ status: bidding.status || "bidding_generated",
+ isPublic: bidding.isPublic || false,
+ managerName: bidding.managerName || "",
+ managerEmail: bidding.managerEmail || "",
+ managerPhone: bidding.managerPhone || "",
+
+ remarks: bidding.remarks || "",
+ })
+ }
+ }, [open, bidding, form])
+
+ // 폼 제출
+ async function onSubmit(data: UpdateBiddingSchema) {
+ if (!bidding) return
+
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || "1"
+ const input: UpdateBiddingInput = {
+ id: bidding.id,
+ ...data,
+ }
+
+ const result = await updateBidding(input, userId)
+
+ if (result.success) {
+ toast.success(result.message)
+ onOpenChange(false)
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "입찰 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Error updating bidding:", error)
+ toast.error("입찰 수정 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 시트 닫기 핸들러
+ const handleOpenChange = (open: boolean) => {
+ onOpenChange(open)
+ if (!open) {
+ form.reset()
+ }
+ }
+
+ if (!bidding) {
+ return null
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={handleOpenChange}>
+ <SheetContent className="flex flex-col h-full sm:max-w-2xl overflow-hidden">
+ <SheetHeader className="flex-shrink-0 text-left pb-6">
+ <SheetTitle>입찰 수정</SheetTitle>
+ <SheetDescription>
+ 입찰 정보를 수정합니다. ({bidding.biddingNumber})
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto pr-2 -mr-2">
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="biddingNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰번호</FormLabel>
+ <FormControl>
+ <Input {...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>입찰명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="projectName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>프로젝트명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="itemName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품목명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 계약 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">계약 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="contractType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약구분</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue />
+ </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>입찰유형</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상태</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingStatusLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 담당자 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">담당자 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name="managerName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>담당자명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="managerEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>이메일</FormLabel>
+ <FormControl>
+ <Input type="email" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="managerPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 비고 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">비고</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Textarea
+ placeholder="추가 메모나 특이사항"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+
+ {/* 고정된 버튼 영역 */}
+ <div className="flex-shrink-0 flex justify-end gap-3 pt-6 mt-6 border-t bg-background">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => handleOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 수정
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file