diff options
Diffstat (limited to 'lib/bidding')
| -rw-r--r-- | lib/bidding/bidding-notice-editor.tsx | 230 | ||||
| -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 | ||||
| -rw-r--r-- | lib/bidding/service.ts | 815 | ||||
| -rw-r--r-- | lib/bidding/validation.ts | 157 |
10 files changed, 4400 insertions, 0 deletions
diff --git a/lib/bidding/bidding-notice-editor.tsx b/lib/bidding/bidding-notice-editor.tsx new file mode 100644 index 00000000..03b993b9 --- /dev/null +++ b/lib/bidding/bidding-notice-editor.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/hooks/use-toast' +import { Save, RefreshCw } from 'lucide-react' +import { BiddingNoticeTemplate } from '@/db/schema/bidding' +import { saveBiddingNoticeTemplate } from './service' +import TiptapEditor from '@/components/qna/tiptap-editor' + +interface BiddingNoticeEditorProps { + initialData: BiddingNoticeTemplate | null +} + +export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) { + const [title, setTitle] = useState(initialData?.title || '표준 입찰공고문') + const [content, setContent] = useState(initialData?.content || getDefaultTemplate()) + const [isPending, startTransition] = useTransition() + const { toast } = useToast() + const router = useRouter() + + const handleSave = () => { + if (!title.trim()) { + toast({ + title: '오류', + description: '제목을 입력해주세요.', + variant: 'destructive', + }) + return + } + + if (!content.trim()) { + toast({ + title: '오류', + description: '내용을 입력해주세요.', + variant: 'destructive', + }) + return + } + + startTransition(async () => { + try { + await saveBiddingNoticeTemplate({ title, content }) + toast({ + title: '성공', + description: '입찰공고문 템플릿이 저장되었습니다.', + }) + router.refresh() + } catch (error) { + toast({ + title: '오류', + description: error instanceof Error ? error.message : '저장에 실패했습니다.', + variant: 'destructive', + }) + } + }) + } + + const handleReset = () => { + if (confirm('기본 템플릿으로 초기화하시겠습니까? 현재 내용은 삭제됩니다.')) { + setTitle('표준 입찰공고문') + setContent(getDefaultTemplate()) + toast({ + title: '초기화 완료', + description: '기본 템플릿으로 초기화되었습니다.', + }) + } + } + + return ( + <div className="space-y-6"> + {/* 제목 입력 */} + <div className="space-y-2"> + <Label htmlFor="title">템플릿 제목</Label> + <Input + id="title" + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="입찰공고문 제목을 입력하세요" + disabled={isPending} + /> + </div> + + {/* 에디터 */} + <div className="space-y-2"> + <Label>공고문 내용</Label> + <div className="border rounded-lg"> + <TiptapEditor + content={content} + setContent={setContent} + disabled={isPending} + height="500px" + /> + </div> + </div> + + {/* 액션 버튼 */} + <div className="flex items-center gap-4 pt-4"> + <Button + onClick={handleSave} + disabled={isPending} + className="min-w-[120px]" + > + {isPending ? ( + <> + <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="w-4 h-4 mr-2" /> + 저장 + </> + )} + </Button> + + <Button + variant="outline" + onClick={handleReset} + disabled={isPending} + > + 기본 템플릿으로 초기화 + </Button> + + {initialData && ( + <div className="ml-auto text-sm text-muted-foreground"> + 마지막 업데이트: {new Date(initialData.updatedAt).toLocaleString('ko-KR')} + </div> + )} + </div> + + {/* 미리보기 힌트 */} + <div className="bg-muted/50 p-4 rounded-lg"> + <p className="text-sm text-muted-foreground"> + <strong>💡 사용 팁:</strong> + 이 템플릿은 실제 입찰 공고 작성 시 기본값으로 사용됩니다. + 회사 정보, 표준 조건, 서식 등을 미리 작성해두면 편리합니다. + </p> + </div> + </div> + ) +} + +// 기본 템플릿 함수 +function getDefaultTemplate(): string { + return ` +<h1>입찰공고</h1> + +<h2>1. 입찰 개요</h2> +<ul> + <li><strong>공고명:</strong> [입찰 공고명을 입력하세요]</li> + <li><strong>입찰방식:</strong> [일반경쟁입찰/제한경쟁입찰]</li> + <li><strong>입찰공고번호:</strong> [공고번호]</li> + <li><strong>공고일자:</strong> [YYYY년 MM월 DD일]</li> +</ul> + +<h2>2. 입찰 참가자격</h2> +<ul> + <li>관련 업종의 사업자등록증을 보유한 업체</li> + <li>부가가치세법에 의한 사업자등록증을 보유한 업체</li> + <li>기타 관련 법령에 따른 자격 요건을 갖춘 업체</li> +</ul> + +<h2>3. 입찰 일정</h2> +<table> + <thead> + <tr> + <th>구분</th> + <th>일시</th> + <th>장소</th> + </tr> + </thead> + <tbody> + <tr> + <td>입찰 공고</td> + <td>[YYYY.MM.DD]</td> + <td>-</td> + </tr> + <tr> + <td>현장설명</td> + <td>[YYYY.MM.DD HH:MM]</td> + <td>[현장 주소]</td> + </tr> + <tr> + <td>입찰서 접수</td> + <td>[YYYY.MM.DD HH:MM까지]</td> + <td>[접수 장소]</td> + </tr> + <tr> + <td>개찰</td> + <td>[YYYY.MM.DD HH:MM]</td> + <td>[개찰 장소]</td> + </tr> + </tbody> +</table> + +<h2>4. 입찰 대상</h2> +<ul> + <li><strong>사업명:</strong> [사업명을 입력하세요]</li> + <li><strong>사업내용:</strong> [상세 사업내용]</li> + <li><strong>사업기간:</strong> [계약일로부터 OO일 이내]</li> + <li><strong>사업장소:</strong> [사업 수행 장소]</li> +</ul> + +<h2>5. 제출 서류</h2> +<ul> + <li>입찰서 및 투찰서</li> + <li>사업자등록증 사본</li> + <li>법인등기부등본 (법인의 경우)</li> + <li>업체현황서</li> + <li>기타 입찰 참가자격 증명서류</li> +</ul> + +<h2>6. 기타 사항</h2> +<ul> + <li>본 입찰공고에 명시되지 않은 사항은 관련 법령에 따릅니다.</li> + <li>기타 문의사항은 아래 연락처로 문의하시기 바랍니다.</li> +</ul> + +<blockquote> +<p><strong>문의처:</strong><br> +담당자: [담당자명]<br> +전화: [전화번호]<br> +이메일: [이메일주소]</p> +</blockquote> + `.trim() +}
\ No newline at end of file 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 diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts new file mode 100644 index 00000000..91fea75e --- /dev/null +++ b/lib/bidding/service.ts @@ -0,0 +1,815 @@ +'use server' + +import db from '@/db/db' +import { + biddings, + biddingListView, + biddingNoticeTemplate, + projects, + biddingDocuments, + prItemsForBidding, + specificationMeetings +} from '@/db/schema' +import { + eq, + desc, + asc, + and, + or, + count, + sql, + ilike, + gte, + lte, + SQL +} from 'drizzle-orm' +import { revalidatePath } from 'next/cache' +import { BiddingListItem } from '@/db/schema' +import { filterColumns } from '@/lib/filter-columns' +import { CreateBiddingSchema, GetBiddingsSchema, UpdateBiddingSchema } from './validation' +import { saveFile } from '../file-stroage' + + +export async function getBiddingNoticeTemplate() { + try { + const result = await db + .select() + .from(biddingNoticeTemplate) + .where(eq(biddingNoticeTemplate.type, 'standard')) + .limit(1) + + return result[0] || null + } catch (error) { + console.error('Failed to get bidding notice template:', error) + throw new Error('입찰공고문 템플릿을 불러오는데 실패했습니다.') + } +} + +export async function saveBiddingNoticeTemplate(formData: { + title: string + content: string +}) { + try { + const { title, content } = formData + + // 기존 템플릿 확인 + const existing = await db + .select() + .from(biddingNoticeTemplate) + .where(eq(biddingNoticeTemplate.type, 'standard')) + .limit(1) + + if (existing.length > 0) { + // 업데이트 + await db + .update(biddingNoticeTemplate) + .set({ + title, + content, + updatedAt: new Date(), + }) + .where(eq(biddingNoticeTemplate.type, 'standard')) + } else { + // 새로 생성 + await db.insert(biddingNoticeTemplate).values({ + type: 'standard', + title, + content, + }) + } + + revalidatePath('/admin/bidding-notice') + return { success: true, message: '입찰공고문 템플릿이 저장되었습니다.' } + } catch (error) { + console.error('Failed to save bidding notice template:', error) + throw new Error('입찰공고문 템플릿 저장에 실패했습니다.') + } +} + + +export async function getBiddings(input: GetBiddingsSchema) { + try { + const offset = (input.page - 1) * input.perPage + + // ✅ 1) 고급 필터 조건 + let advancedWhere: SQL<unknown> | undefined = undefined + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: biddingListView, + filters: input.filters, + joinOperator: input.joinOperator || 'and', + }) + } + + // ✅ 2) 기본 필터 조건들 + const basicConditions: SQL<unknown>[] = [] + + if (input.biddingNumber) { + basicConditions.push(ilike(biddingListView.biddingNumber, `%${input.biddingNumber}%`)) + } + + if (input.status && input.status.length > 0) { + basicConditions.push( + or(...input.status.map(status => eq(biddingListView.status, status)))! + ) + } + + if (input.biddingType && input.biddingType.length > 0) { + basicConditions.push( + or(...input.biddingType.map(type => eq(biddingListView.biddingType, type)))! + ) + } + + if (input.contractType && input.contractType.length > 0) { + basicConditions.push( + or(...input.contractType.map(type => eq(biddingListView.contractType, type)))! + ) + } + + if (input.managerName) { + basicConditions.push(ilike(biddingListView.managerName, `%${input.managerName}%`)) + } + + // 날짜 필터들 + if (input.preQuoteDateFrom) { + basicConditions.push(gte(biddingListView.preQuoteDate, input.preQuoteDateFrom)) + } + if (input.preQuoteDateTo) { + basicConditions.push(lte(biddingListView.preQuoteDate, input.preQuoteDateTo)) + } + + if (input.submissionDateFrom) { + basicConditions.push(gte(biddingListView.submissionStartDate, input.submissionDateFrom)) + } + if (input.submissionDateTo) { + basicConditions.push(lte(biddingListView.submissionEndDate, input.submissionDateTo)) + } + + if (input.createdAtFrom) { + basicConditions.push(gte(biddingListView.createdAt, input.createdAtFrom)) + } + if (input.createdAtTo) { + basicConditions.push(lte(biddingListView.createdAt, input.createdAtTo)) + } + + // 가격 범위 필터 + if (input.budgetMin) { + basicConditions.push(gte(biddingListView.budget, input.budgetMin)) + } + if (input.budgetMax) { + basicConditions.push(lte(biddingListView.budget, input.budgetMax)) + } + + // Boolean 필터 + if (input.hasSpecificationMeeting === "true") { + basicConditions.push(eq(biddingListView.hasSpecificationMeeting, true)) + } else if (input.hasSpecificationMeeting === "false") { + basicConditions.push(eq(biddingListView.hasSpecificationMeeting, false)) + } + + if (input.hasPrDocument === "true") { + basicConditions.push(eq(biddingListView.hasPrDocument, true)) + } else if (input.hasPrDocument === "false") { + basicConditions.push(eq(biddingListView.hasPrDocument, false)) + } + + const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined + + // ✅ 3) 글로벌 검색 조건 + let globalWhere: SQL<unknown> | undefined = undefined + if (input.search) { + const s = `%${input.search}%` + const searchConditions = [ + ilike(biddingListView.biddingNumber, s), + ilike(biddingListView.title, s), + ilike(biddingListView.projectName, s), + ilike(biddingListView.itemName, s), + ilike(biddingListView.managerName, s), + ilike(biddingListView.prNumber, s), + ilike(biddingListView.remarks, s), + ] + globalWhere = or(...searchConditions) + } + + // ✅ 4) 최종 WHERE 조건 + const whereConditions: SQL<unknown>[] = [] + if (advancedWhere) whereConditions.push(advancedWhere) + if (basicWhere) whereConditions.push(basicWhere) + if (globalWhere) whereConditions.push(globalWhere) + + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined + + // ✅ 5) 전체 개수 조회 + const totalResult = await db + .select({ count: count() }) + .from(biddingListView) + .where(finalWhere) + + const total = totalResult[0]?.count || 0 + + if (total === 0) { + return { data: [], pageCount: 0, total: 0 } + } + + console.log("Total biddings:", total) + + // ✅ 6) 정렬 및 페이징 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof biddingListView.$inferSelect + return sort.desc ? desc(biddingListView[column]) : asc(biddingListView[column]) + }) + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(biddingListView.createdAt)) + } + + // ✅ 7) 메인 쿼리 - 매우 간단해짐! + const data = await db + .select() + .from(biddingListView) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset) + + const pageCount = Math.ceil(total / input.perPage) + + // ✅ 8) 포맷팅 불필요 - 뷰에서 이미 완성된 데이터! + return { data, pageCount, total } + + } catch (err) { + console.error("Error in getBiddings:", err) + return { data: [], pageCount: 0, total: 0 } + } +} +// 상태별 개수 집계 +export async function getBiddingStatusCounts() { + try { + const counts = await db + .select({ + status: biddings.status, + count: count(), + }) + .from(biddings) + .groupBy(biddings.status) + + return counts.reduce((acc, { status, count }) => { + acc[status] = count + return acc + }, {} as Record<string, number>) + } catch (error) { + console.error('Failed to get bidding status counts:', error) + return {} + } +} + +// 입찰유형별 개수 집계 +export async function getBiddingTypeCounts() { + try { + const counts = await db + .select({ + biddingType: biddings.biddingType, + count: count(), + }) + .from(biddings) + .groupBy(biddings.biddingType) + + return counts.reduce((acc, { biddingType, count }) => { + acc[biddingType] = count + return acc + }, {} as Record<string, number>) + } catch (error) { + console.error('Failed to get bidding type counts:', error) + return {} + } +} + +// 담당자별 개수 집계 +export async function getBiddingManagerCounts() { + try { + const counts = await db + .select({ + managerName: biddings.managerName, + count: count(), + }) + .from(biddings) + .where(sql`${biddings.managerName} IS NOT NULL AND ${biddings.managerName} != ''`) + .groupBy(biddings.managerName) + + return counts.reduce((acc, { managerName, count }) => { + if (managerName) { + acc[managerName] = count + } + return acc + }, {} as Record<string, number>) + } catch (error) { + console.error('Failed to get bidding manager counts:', error) + return {} + } +} + +// 월별 입찰 생성 통계 +export async function getBiddingMonthlyStats(year: number = new Date().getFullYear()) { + try { + const stats = await db + .select({ + month: sql<number>`EXTRACT(MONTH FROM ${biddings.createdAt})`.as('month'), + count: count(), + }) + .from(biddings) + .where(sql`EXTRACT(YEAR FROM ${biddings.createdAt}) = ${year}`) + .groupBy(sql`EXTRACT(MONTH FROM ${biddings.createdAt})`) + .orderBy(sql`EXTRACT(MONTH FROM ${biddings.createdAt})`) + + // 1-12월 전체 배열 생성 (없는 월은 0으로) + const monthlyData = Array.from({ length: 12 }, (_, i) => { + const month = i + 1 + const found = stats.find(stat => stat.month === month) + return { + month, + count: found?.count || 0, + } + }) + + return monthlyData + } catch (error) { + console.error('Failed to get bidding monthly stats:', error) + return [] + } +} + +export interface CreateBiddingInput extends CreateBiddingSchema { + // 사양설명회 정보 (선택사항) + specificationMeeting?: { + meetingDate: string + meetingTime: string + location: string + address: string + contactPerson: string + contactPhone: string + contactEmail: string + agenda: string + materials: string + notes: string + isRequired: boolean + } | null + + // PR 아이템들 (선택사항) + prItemsForBidding?: Array<{ + itemNumber: string + projectInfo: string + itemInfo: string + shi: string + requestedDeliveryDate: string + annualUnitPrice: string + currency: string + quantity: string + quantityUnit: string + totalWeight: string + weightUnit: string + materialDescription: string + prNumber: string + specFiles: File[] + }> + } + +export interface UpdateBiddingInput extends UpdateBiddingSchema { + id: number +} + +// 자동 입찰번호 생성 +async function generateBiddingNumber(biddingType: string): Promise<string> { + const year = new Date().getFullYear() + const typePrefix = { + 'equipment': 'EQ', + 'construction': 'CT', + 'service': 'SV', + 'lease': 'LS', + 'steel_stock': 'SS', + 'piping': 'PP', + 'transport': 'TP', + 'waste': 'WS', + 'sale': 'SL' + }[biddingType] || 'GN' + + // 해당 연도의 마지막 번호 조회 + const lastBidding = await db + .select({ biddingNumber: biddings.biddingNumber }) + .from(biddings) + .where(eq(biddings.biddingNumber, `${year}${typePrefix}%`)) + .orderBy(biddings.biddingNumber) + .limit(1) + + let sequence = 1 + if (lastBidding.length > 0) { + const lastNumber = lastBidding[0].biddingNumber + const lastSequence = parseInt(lastNumber.slice(-4)) + sequence = lastSequence + 1 + } + + return `${year}${typePrefix}${sequence.toString().padStart(4, '0')}` +} + +// 입찰 생성 +export async function createBidding(input: CreateBiddingInput, userId: string) { + try { + return await db.transaction(async (tx) => { + // 자동 입찰번호 생성 + const biddingNumber = await generateBiddingNumber(input.biddingType) + + // 프로젝트 정보 조회 + let projectName = input.projectName + if (input.projectId && !projectName) { + const project = await tx + .select({ code: projects.code, name: projects.name }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .limit(1) + + if (project.length > 0) { + projectName = `${project[0].code} (${project[0].name})` + } + } + + // 표준 공고문 템플릿 가져오기 + let standardContent = '' + if (!input.content) { + try { + const template = await tx + .select({ content: biddingNoticeTemplate.content }) + .from(biddingNoticeTemplate) + .where(eq(biddingNoticeTemplate.type, 'standard')) + .limit(1) + + if (template.length > 0) { + standardContent = template[0].content + } + } catch (error) { + console.warn('Failed to load standard template:', error) + } + } + + // 날짜 변환 함수 + const parseDate = (dateStr?: string) => { + if (!dateStr) return null + try { + return new Date(dateStr) + } catch { + return null + } + } + + // 1. 입찰 생성 + const [newBidding] = await tx + .insert(biddings) + .values({ + biddingNumber, + revision: input.revision || 0, + + // 프로젝트 정보 + projectId: input.projectId, + projectName, + + itemName: input.itemName, + title: input.title, + description: input.description, + content: input.content || standardContent, + + contractType: input.contractType, + biddingType: input.biddingType, + awardCount: input.awardCount, + contractPeriod: input.contractPeriod, + + // 자동 등록일 설정 + biddingRegistrationDate: new Date(), + submissionStartDate: parseDate(input.submissionStartDate), + submissionEndDate: parseDate(input.submissionEndDate), + evaluationDate: parseDate(input.evaluationDate), + + hasSpecificationMeeting: input.hasSpecificationMeeting || false, + hasPrDocument: input.hasPrDocument || false, + prNumber: input.prNumber, + + currency: input.currency, + budget: input.budget ? parseFloat(input.budget) : null, + targetPrice: input.targetPrice ? parseFloat(input.targetPrice) : null, + finalBidPrice: input.finalBidPrice ? parseFloat(input.finalBidPrice) : null, + + status: input.status || 'bidding_generated', + isPublic: input.isPublic || false, + managerName: input.managerName, + managerEmail: input.managerEmail, + managerPhone: input.managerPhone, + + remarks: input.remarks, + createdBy: userId, + updatedBy: userId, + }) + .returning({ id: biddings.id }) + + const biddingId = newBidding.id + + // 2. 사양설명회 정보 저장 (있는 경우) + if (input.specificationMeeting) { + const [newSpecMeeting] = await tx + .insert(specificationMeetings) + .values({ + biddingId, + meetingDate: new Date(input.specificationMeeting.meetingDate), + meetingTime: input.specificationMeeting.meetingTime, + location: input.specificationMeeting.location, + address: input.specificationMeeting.address, + contactPerson: input.specificationMeeting.contactPerson, + contactPhone: input.specificationMeeting.contactPhone, + contactEmail: input.specificationMeeting.contactEmail, + agenda: input.specificationMeeting.agenda, + materials: input.specificationMeeting.materials, + notes: input.specificationMeeting.notes, + isRequired: input.specificationMeeting.isRequired, + }) + .returning({ id: specificationMeetings.id }) + + // 2-1. 사양설명회 첨부파일 저장 + if (input.specificationMeeting.meetingFiles && input.specificationMeeting.meetingFiles.length > 0) { + for (const file of input.specificationMeeting.meetingFiles) { + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${biddingId}/specification-meeting`, + originalName: file.name, + userId + }) + + if (saveResult.success) { + await tx.insert(biddingDocuments).values({ + biddingId, + specificationMeetingId: newSpecMeeting.id, + documentType: 'specification_meeting', + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + fileSize: saveResult.fileSize!, + mimeType: file.type, + filePath: saveResult.filePath!, + publicPath: saveResult.publicPath, + title: `사양설명회 - ${file.name}`, + isPublic: false, + isRequired: false, + uploadedBy: userId, + }) + } else { + console.error(`Failed to save specification meeting file: ${file.name}`, saveResult.error) + // 파일 저장 실패해도 전체 트랜잭션은 계속 진행 + } + } catch (error) { + console.error(`Error saving specification meeting file: ${file.name}`, error) + } + } + } + } + + // 3. PR 아이템들 저장 (있는 경우) + if (input.prItems && input.prItems.length > 0) { + for (const prItem of input.prItems) { + // PR 아이템 저장 + const [newPrItem] = await tx.insert(prItemsForBidding).values({ + biddingId, + itemNumber: prItem.itemCode, // itemCode를 itemNumber로 매핑 + projectInfo: '', // 필요시 추가 + itemInfo: prItem.itemInfo, + shi: '', // 필요시 추가 + requestedDeliveryDate: prItem.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : null, + annualUnitPrice: null, // 필요시 추가 + currency: 'KRW', // 기본값 또는 입력받은 값 + quantity: prItem.quantity ? parseFloat(prItem.quantity) : null, + quantityUnit: prItem.quantityUnit as any, // enum 타입에 맞게 + totalWeight: null, // 필요시 추가 + weightUnit: null, // 필요시 추가 + materialDescription: '', // 필요시 추가 + prNumber: prItem.prNumber, + hasSpecDocument: prItem.specFiles.length > 0, + isRepresentative: prItem.isRepresentative, + }).returning({ id: prItemsForBidding.id }) + + // 3-1. 스펙 파일들 저장 (있는 경우) + if (prItem.specFiles.length > 0) { + for (let fileIndex = 0; fileIndex < prItem.specFiles.length; fileIndex++) { + const file = prItem.specFiles[fileIndex] + try { + const saveResult = await saveFile({ + file, + directory: `biddings/${biddingId}/pr-items/${newPrItem.id}/specs`, + originalName: file.name, + userId + }) + + if (saveResult.success) { + await tx.insert(biddingDocuments).values({ + biddingId, + prItemId: newPrItem.id, + documentType: 'spec', + fileName: saveResult.fileName!, + originalFileName: saveResult.originalName!, + fileSize: saveResult.fileSize!, + mimeType: file.type, + filePath: saveResult.filePath!, + publicPath: saveResult.publicPath, + title: `${prItem.itemInfo || prItem.itemCode} 스펙 - ${file.name}`, + description: `PR ${prItem.prNumber}의 스펙 문서`, + isPublic: false, + isRequired: false, + uploadedBy: userId, + displayOrder: fileIndex + 1, + }) + } else { + console.error(`Failed to save spec file: ${file.name}`, saveResult.error) + // 파일 저장 실패해도 전체 트랜잭션은 계속 진행 + } + } catch (error) { + console.error(`Error saving spec file: ${file.name}`, error) + } + } + } + } + } + + // 캐시 무효화 + revalidatePath('/evcp/bid') + + return { + success: true, + message: '입찰이 성공적으로 생성되었습니다.', + data: { id: biddingId, biddingNumber } + } + }) + } catch (error) { + console.error('Error creating bidding:', error) + return { + success: false, + error: error instanceof Error ? error.message : '입찰 생성 중 오류가 발생했습니다.' + } + } + } +// 입찰 수정 +export async function updateBidding(input: UpdateBiddingInput, userId: string) { + try { + // 존재 여부 확인 + const existing = await db + .select({ id: biddings.id }) + .from(biddings) + .where(eq(biddings.id, input.id)) + .limit(1) + + if (existing.length === 0) { + return { + success: false, + error: '존재하지 않는 입찰입니다.' + } + } + + // 입찰번호 중복 체크 (다른 레코드에서) + if (input.biddingNumber) { + const duplicate = await db + .select({ id: biddings.id }) + .from(biddings) + .where(eq(biddings.biddingNumber, input.biddingNumber)) + .limit(1) + + if (duplicate.length > 0 && duplicate[0].id !== input.id) { + return { + success: false, + error: '이미 존재하는 입찰번호입니다.' + } + } + } + + // 날짜 문자열을 Date 객체로 변환 + const parseDate = (dateStr?: string) => { + if (!dateStr) return undefined + try { + return new Date(dateStr) + } catch { + return undefined + } + } + + // 업데이트할 데이터 준비 + const updateData: any = { + updatedAt: new Date(), + updatedBy: userId, + } + + // 정의된 필드들만 업데이트 + if (input.biddingNumber !== undefined) updateData.biddingNumber = input.biddingNumber + if (input.revision !== undefined) updateData.revision = input.revision + if (input.projectName !== undefined) updateData.projectName = input.projectName + if (input.itemName !== undefined) updateData.itemName = input.itemName + if (input.title !== undefined) updateData.title = input.title + if (input.description !== undefined) updateData.description = input.description + if (input.content !== undefined) updateData.content = input.content + + if (input.contractType !== undefined) updateData.contractType = input.contractType + if (input.biddingType !== undefined) updateData.biddingType = input.biddingType + if (input.awardCount !== undefined) updateData.awardCount = input.awardCount + if (input.contractPeriod !== undefined) updateData.contractPeriod = input.contractPeriod + + if (input.preQuoteDate !== undefined) updateData.preQuoteDate = parseDate(input.preQuoteDate) + if (input.biddingRegistrationDate !== undefined) updateData.biddingRegistrationDate = parseDate(input.biddingRegistrationDate) + if (input.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(input.submissionStartDate) + if (input.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(input.submissionEndDate) + if (input.evaluationDate !== undefined) updateData.evaluationDate = parseDate(input.evaluationDate) + + if (input.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = input.hasSpecificationMeeting + if (input.hasPrDocument !== undefined) updateData.hasPrDocument = input.hasPrDocument + if (input.prNumber !== undefined) updateData.prNumber = input.prNumber + + if (input.currency !== undefined) updateData.currency = input.currency + if (input.budget !== undefined) updateData.budget = input.budget ? parseFloat(input.budget) : null + if (input.targetPrice !== undefined) updateData.targetPrice = input.targetPrice ? parseFloat(input.targetPrice) : null + if (input.finalBidPrice !== undefined) updateData.finalBidPrice = input.finalBidPrice ? parseFloat(input.finalBidPrice) : null + + if (input.status !== undefined) updateData.status = input.status + if (input.isPublic !== undefined) updateData.isPublic = input.isPublic + if (input.managerName !== undefined) updateData.managerName = input.managerName + if (input.managerEmail !== undefined) updateData.managerEmail = input.managerEmail + if (input.managerPhone !== undefined) updateData.managerPhone = input.managerPhone + + if (input.remarks !== undefined) updateData.remarks = input.remarks + + // 입찰 수정 + await db + .update(biddings) + .set(updateData) + .where(eq(biddings.id, input.id)) + + revalidatePath('/admin/biddings') + revalidatePath(`/admin/biddings/${input.id}`) + + return { + success: true, + message: '입찰이 성공적으로 수정되었습니다.' + } + + } catch (error) { + console.error('Error updating bidding:', error) + return { + success: false, + error: '입찰 수정 중 오류가 발생했습니다.' + } + } +} + +// 입찰 삭제 +export async function deleteBidding(id: number) { + try { + const existing = await db + .select({ id: biddings.id }) + .from(biddings) + .where(eq(biddings.id, id)) + .limit(1) + + if (existing.length === 0) { + return { + success: false, + error: '존재하지 않는 입찰입니다.' + } + } + + await db + .delete(biddings) + .where(eq(biddings.id, id)) + + revalidatePath('/admin/biddings') + + return { + success: true, + message: '입찰이 성공적으로 삭제되었습니다.' + } + + } catch (error) { + console.error('Error deleting bidding:', error) + return { + success: false, + error: '입찰 삭제 중 오류가 발생했습니다.' + } + } +} + +// 단일 입찰 조회 +export async function getBiddingById(id: number) { + try { + const bidding = await db + .select() + .from(biddings) + .where(eq(biddings.id, id)) + .limit(1) + + if (bidding.length === 0) { + return null + } + + return bidding[0] + } catch (error) { + console.error('Error getting bidding:', error) + return null + } +} diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts new file mode 100644 index 00000000..3d47aefe --- /dev/null +++ b/lib/bidding/validation.ts @@ -0,0 +1,157 @@ +import { biddings, type Bidding } from "@/db/schema" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<Bidding>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 기본 필터 + biddingNumber: parseAsString.withDefault(""), + status: parseAsArrayOf(z.enum(biddings.status.enumValues)).withDefault([]), + biddingType: parseAsArrayOf(z.enum(biddings.biddingType.enumValues)).withDefault([]), + contractType: parseAsArrayOf(z.enum(biddings.contractType.enumValues)).withDefault([]), + managerName: parseAsString.withDefault(""), + + // 날짜 필터 + preQuoteDateFrom: parseAsString.withDefault(""), + preQuoteDateTo: parseAsString.withDefault(""), + submissionDateFrom: parseAsString.withDefault(""), + submissionDateTo: parseAsString.withDefault(""), + createdAtFrom: parseAsString.withDefault(""), + createdAtTo: parseAsString.withDefault(""), + + // 가격 필터 + budgetMin: parseAsString.withDefault(""), + budgetMax: parseAsString.withDefault(""), + + // Boolean 필터 + hasSpecificationMeeting: parseAsString.withDefault(""), // "true" | "false" | "" + hasPrDocument: parseAsString.withDefault(""), // "true" | "false" | "" + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +export const createBiddingSchema = z.object({ + // ❌ 제거: biddingNumber (자동 생성) + // ❌ 제거: preQuoteDate (나중에 자동 기록) + // ❌ 제거: biddingRegistrationDate (시스템에서 자동 기록) + + revision: z.number().int().min(0).default(0), + + // ✅ 프로젝트 정보 (새로 추가) + projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수 + projectName: z.string().optional(), // ProjectSelector에서 자동 설정 + + // ✅ 필수 필드들 + itemName: z.string().min(1, "품목명은 필수입니다"), + title: z.string().min(1, "입찰명은 필수입니다"), + description: z.string().optional(), + content: z.string().optional(), + + // ✅ 계약 정보 (필수) + contractType: z.enum(biddings.contractType.enumValues, { + required_error: "계약구분을 선택해주세요" + }), + biddingType: z.enum(biddings.biddingType.enumValues, { + required_error: "입찰유형을 선택해주세요" + }), + awardCount: z.enum(biddings.awardCount.enumValues, { + required_error: "낙찰수를 선택해주세요" + }), + contractPeriod: z.string().min(1, "계약기간은 필수입니다"), + + // ✅ 일정 (제출기간 필수) + submissionStartDate: z.string().min(1, "제출시작일시는 필수입니다"), + submissionEndDate: z.string().min(1, "제출마감일시는 필수입니다"), + evaluationDate: z.string().optional(), + + // 회의 및 문서 + hasSpecificationMeeting: z.boolean().default(false), + hasPrDocument: z.boolean().default(false), + prNumber: z.string().optional(), + + // ✅ 가격 정보 (통화 필수) + currency: z.string().min(1, "통화를 선택해주세요").default("KRW"), + budget: z.string().optional(), + targetPrice: z.string().optional(), + finalBidPrice: z.string().optional(), + + // 상태 및 담당자 + status: z.enum(biddings.status.enumValues).default("bidding_generated"), + isPublic: z.boolean().default(false), + managerName: z.string().optional(), + managerEmail: z.string().email().optional().or(z.literal("")), + managerPhone: z.string().optional(), + + // 메타 + remarks: z.string().optional(), + }).refine((data) => { + // 제출 기간 검증: 시작일이 마감일보다 이전이어야 함 + if (data.submissionStartDate && data.submissionEndDate) { + const startDate = new Date(data.submissionStartDate) + const endDate = new Date(data.submissionEndDate) + return startDate < endDate + } + return true + }, { + message: "제출시작일시가 제출마감일시보다 늦을 수 없습니다", + path: ["submissionEndDate"] + }) + + export const updateBiddingSchema = z.object({ + biddingNumber: z.string().min(1, "입찰번호는 필수입니다").optional(), + revision: z.number().int().min(0).optional(), + + projectId: z.number().min(1).optional(), + projectName: z.string().optional(), + itemName: z.string().min(1, "품목명은 필수입니다").optional(), + title: z.string().min(1, "입찰명은 필수입니다").optional(), + description: z.string().optional(), + content: z.string().optional(), + + contractType: z.enum(biddings.contractType.enumValues).optional(), + biddingType: z.enum(biddings.biddingType.enumValues).optional(), + awardCount: z.enum(biddings.awardCount.enumValues).optional(), + contractPeriod: z.string().optional(), + + submissionStartDate: z.string().optional(), + submissionEndDate: z.string().optional(), + evaluationDate: z.string().optional(), + + hasSpecificationMeeting: z.boolean().optional(), + hasPrDocument: z.boolean().optional(), + prNumber: z.string().optional(), + + currency: z.string().optional(), + budget: z.string().optional(), + targetPrice: z.string().optional(), + finalBidPrice: z.string().optional(), + + status: z.enum(biddings.status.enumValues).optional(), + isPublic: z.boolean().optional(), + managerName: z.string().optional(), + managerEmail: z.string().email().optional().or(z.literal("")), + managerPhone: z.string().optional(), + + remarks: z.string().optional(), + }) + + export type GetBiddingsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> + export type CreateBiddingSchema = z.infer<typeof createBiddingSchema> + export type UpdateBiddingSchema = z.infer<typeof updateBiddingSchema> |
