diff options
Diffstat (limited to 'lib/bidding/list')
| -rw-r--r-- | lib/bidding/list/biddings-page-header.tsx | 41 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-stats-cards.tsx | 122 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-columns.tsx | 578 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table-toolbar-actions.tsx | 143 | ||||
| -rw-r--r-- | lib/bidding/list/biddings-table.tsx | 135 | ||||
| -rw-r--r-- | lib/bidding/list/create-bidding-dialog.tsx | 1674 | ||||
| -rw-r--r-- | lib/bidding/list/edit-bidding-sheet.tsx | 505 |
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 |
