From cbb4c7fe0b94459162ad5e998bc05cd293e0ff96 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 11 Aug 2025 09:02:00 +0000 Subject: (대표님) 입찰, EDP 변경사항 대응, spreadJS 오류 수정, 벤더실사 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/bidding/bidding-notice-editor.tsx | 230 +++ lib/bidding/list/biddings-page-header.tsx | 41 + lib/bidding/list/biddings-stats-cards.tsx | 122 ++ lib/bidding/list/biddings-table-columns.tsx | 578 +++++++ .../list/biddings-table-toolbar-actions.tsx | 143 ++ lib/bidding/list/biddings-table.tsx | 135 ++ lib/bidding/list/create-bidding-dialog.tsx | 1674 ++++++++++++++++++++ lib/bidding/list/edit-bidding-sheet.tsx | 505 ++++++ lib/bidding/service.ts | 815 ++++++++++ lib/bidding/validation.ts | 157 ++ lib/forms/services.ts | 16 +- lib/pq/helper.ts | 2 - lib/pq/service.ts | 3 - lib/sedp/get-form-tags.ts | 41 +- lib/sedp/get-tags.ts | 55 +- lib/sedp/sync-form.ts | 61 +- lib/tags/service.ts | 66 +- lib/tags/table/add-tag-dialog.tsx | 46 +- .../enhanced-document-service.ts | 11 +- .../plant/document-stage-dialogs.tsx | 892 ++++++++--- .../plant/document-stages-expanded-content.tsx | 98 +- .../plant/document-stages-service.ts | 314 ++-- .../plant/document-stages-table.tsx | 10 - .../ship/import-from-dolce-button.tsx | 152 +- .../ship/send-to-shi-button.tsx | 127 +- 25 files changed, 5719 insertions(+), 575 deletions(-) create mode 100644 lib/bidding/bidding-notice-editor.tsx create mode 100644 lib/bidding/list/biddings-page-header.tsx create mode 100644 lib/bidding/list/biddings-stats-cards.tsx create mode 100644 lib/bidding/list/biddings-table-columns.tsx create mode 100644 lib/bidding/list/biddings-table-toolbar-actions.tsx create mode 100644 lib/bidding/list/biddings-table.tsx create mode 100644 lib/bidding/list/create-bidding-dialog.tsx create mode 100644 lib/bidding/list/edit-bidding-sheet.tsx create mode 100644 lib/bidding/service.ts create mode 100644 lib/bidding/validation.ts (limited to 'lib') 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 ( +
+ {/* 제목 입력 */} +
+ + setTitle(e.target.value)} + placeholder="입찰공고문 제목을 입력하세요" + disabled={isPending} + /> +
+ + {/* 에디터 */} +
+ +
+ +
+
+ + {/* 액션 버튼 */} +
+ + + + + {initialData && ( +
+ 마지막 업데이트: {new Date(initialData.updatedAt).toLocaleString('ko-KR')} +
+ )} +
+ + {/* 미리보기 힌트 */} +
+

+ 💡 사용 팁: + 이 템플릿은 실제 입찰 공고 작성 시 기본값으로 사용됩니다. + 회사 정보, 표준 조건, 서식 등을 미리 작성해두면 편리합니다. +

+
+
+ ) +} + +// 기본 템플릿 함수 +function getDefaultTemplate(): string { + return ` +

입찰공고

+ +

1. 입찰 개요

+ + +

2. 입찰 참가자격

+ + +

3. 입찰 일정

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
구분일시장소
입찰 공고[YYYY.MM.DD]-
현장설명[YYYY.MM.DD HH:MM][현장 주소]
입찰서 접수[YYYY.MM.DD HH:MM까지][접수 장소]
개찰[YYYY.MM.DD HH:MM][개찰 장소]
+ +

4. 입찰 대상

+ + +

5. 제출 서류

+ + +

6. 기타 사항

+ + +
+

문의처:
+담당자: [담당자명]
+전화: [전화번호]
+이메일: [이메일주소]

+
+ `.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 ( +
+ {/* 좌측: 제목과 설명 */} +
+

입찰 목록 관리

+

+ 입찰 공고를 생성하고 진행 상황을 관리할 수 있습니다. +

+
+ + {/* 우측: 액션 버튼들 */} +
+ + + + +
+
+ ) +} \ 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 + typeCounts: Record + managerCounts: Record + 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 ( +
+ {/* 전체 입찰 수 */} + + + 전체 입찰 + + + +
{total.toLocaleString()}
+

+ 이번 달 +{thisMonthCount}건 +

+
+
+ + {/* 진행중인 입찰 */} + + + 진행중 + + + +
{activeBiddingsCount}
+
+ {activeStatuses.map(status => ( + statusCounts[status] > 0 && ( + + {biddingStatusLabels[status]}: {statusCounts[status]} + + ) + ))} +
+
+
+ + {/* 완료된 입찰 */} + + + 완료 + + + +
{completedCount}
+

+ 완료율 {total > 0 ? ((completedCount / total) * 100).toFixed(1) : 0}% +

+
+
+ + {/* 월별 증감 */} + + + 월별 증감 + + + +
+ = 0 ? 'text-green-600' : 'text-red-600'}> + {Number(monthlyGrowth) >= 0 ? '+' : ''}{monthlyGrowth}% + +
+

+ 지난 달 대비 +

+
+
+
+ ) +} 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 | 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[] { + return [ + // ═══════════════════════════════════════════════════════════════ + // 선택 및 기본 정보 + // ═══════════════════════════════════════════════════════════════ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // ░░░ 입찰 No. ░░░ + { + accessorKey: "biddingNumber", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.biddingNumber} + {row.original.revision > 0 && ( + + Rev.{row.original.revision} + + )} +
+ ), + size: 120, + meta: { excelHeader: "입찰 No." }, + }, + + // ░░░ 입찰상태 ░░░ + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => ( + + {biddingStatusLabels[row.original.status]} + + ), + size: 120, + meta: { excelHeader: "입찰상태" }, + }, + + // ░░░ 사전견적 ░░░ + { + id: "preQuote", + header: ({ column }) => , + cell: ({ row }) => { + const hasPreQuote = ['request_for_quotation', 'received_quotation'].includes(row.original.status) + const preQuoteDate = row.original.preQuoteDate + + return hasPreQuote ? ( +
+ + {preQuoteDate && ( + + {formatDate(preQuoteDate, "KR")} + + )} +
+ ) : ( + + ) + }, + size: 90, + meta: { excelHeader: "사전견적" }, + }, + + // ░░░ 입찰담당자 ░░░ + { + accessorKey: "managerName", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.managerName || '-'} +
+ ), + size: 100, + meta: { excelHeader: "입찰담당자" }, + }, + + // ═══════════════════════════════════════════════════════════════ + // 프로젝트 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "프로젝트 정보", + columns: [ + { + accessorKey: "projectName", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.projectName || '-'} +
+ ), + size: 150, + meta: { excelHeader: "프로젝트명" }, + }, + + { + accessorKey: "itemName", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.itemName || '-'} +
+ ), + size: 150, + meta: { excelHeader: "품목명" }, + }, + + { + accessorKey: "title", + header: ({ column }) => , + cell: ({ row }) => ( +
+ +
+ ), + size: 200, + meta: { excelHeader: "입찰명" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 계약 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "계약 정보", + columns: [ + { + accessorKey: "contractType", + header: ({ column }) => , + cell: ({ row }) => ( + + {contractTypeLabels[row.original.contractType]} + + ), + size: 100, + meta: { excelHeader: "계약구분" }, + }, + + { + accessorKey: "biddingType", + header: ({ column }) => , + cell: ({ row }) => ( + + {biddingTypeLabels[row.original.biddingType]} + + ), + size: 100, + meta: { excelHeader: "입찰유형" }, + }, + + { + accessorKey: "awardCount", + header: ({ column }) => , + cell: ({ row }) => ( + + {awardCountLabels[row.original.awardCount]} + + ), + size: 80, + meta: { excelHeader: "낙찰수" }, + }, + + { + accessorKey: "contractPeriod", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.contractPeriod || '-'} + ), + size: 100, + meta: { excelHeader: "계약기간" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 일정 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "일정 정보", + columns: [ + { + id: "submissionPeriod", + header: ({ column }) => , + cell: ({ row }) => { + const startDate = row.original.submissionStartDate + const endDate = row.original.submissionEndDate + + if (!startDate || !endDate) return - + + const now = new Date() + const isActive = now >= new Date(startDate) && now <= new Date(endDate) + const isPast = now > new Date(endDate) + + return ( +
+
+ {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")} +
+ {isActive && ( + 진행중 + )} +
+ ) + }, + size: 140, + meta: { excelHeader: "입찰서제출기간" }, + }, + + { + accessorKey: "hasSpecificationMeeting", + header: ({ column }) => , + cell: ({ row }) => { + const hasMeeting = row.original.hasSpecificationMeeting + + return ( + + ) + }, + size: 100, + meta: { excelHeader: "사양설명회" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 가격 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "가격 정보", + columns: [ + { + accessorKey: "currency", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.currency} + ), + size: 60, + meta: { excelHeader: "통화" }, + }, + + { + accessorKey: "budget", + header: ({ column }) => , + cell: ({ row }) => ( + + {formatCurrency(row.original.budget, row.original.currency)} + + ), + size: 120, + meta: { excelHeader: "예산" }, + }, + + { + accessorKey: "targetPrice", + header: ({ column }) => , + cell: ({ row }) => ( + + {formatCurrency(row.original.targetPrice, row.original.currency)} + + ), + size: 120, + meta: { excelHeader: "내정가" }, + }, + + { + accessorKey: "finalBidPrice", + header: ({ column }) => , + cell: ({ row }) => ( + + {formatCurrency(row.original.finalBidPrice, row.original.currency)} + + ), + size: 120, + meta: { excelHeader: "최종입찰가" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 참여 현황 + // ═══════════════════════════════════════════════════════════════ + { + header: "참여 현황", + columns: [ + { + id: "participantExpected", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.participantStats.expected} + + ), + size: 80, + meta: { excelHeader: "참여예정" }, + }, + + { + id: "participantParticipated", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.participantStats.participated} + + ), + size: 60, + meta: { excelHeader: "참여" }, + }, + + { + id: "participantDeclined", + header: ({ column }) => , + cell: ({ row }) => ( + + {row.original.participantStats.declined} + + ), + size: 60, + meta: { excelHeader: "포기" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // PR 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "PR 정보", + columns: [ + { + accessorKey: "prNumber", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.prNumber || '-'} + ), + size: 100, + meta: { excelHeader: "PR No." }, + }, + + { + accessorKey: "hasPrDocument", + header: ({ column }) => , + cell: ({ row }) => { + const hasPrDoc = row.original.hasPrDocument + + return ( + + ) + }, + size: 80, + meta: { excelHeader: "PR 문서" }, + }, + ] + }, + + // ═══════════════════════════════════════════════════════════════ + // 메타 정보 + // ═══════════════════════════════════════════════════════════════ + { + header: "메타 정보", + columns: [ + { + accessorKey: "preQuoteDate", + header: ({ column }) => , + cell: ({ row }) => ( + {formatDate(row.original.preQuoteDate, "KR")} + ), + size: 90, + meta: { excelHeader: "사전견적일" }, + }, + + { + accessorKey: "biddingRegistrationDate", + header: ({ column }) => , + cell: ({ row }) => ( + {formatDate(row.original.biddingRegistrationDate , "KR")} + ), + size: 100, + meta: { excelHeader: "입찰등록일" }, + }, + + { + accessorKey: "updatedAt", + header: ({ column }) => , + cell: ({ row }) => ( + {formatDate(row.original.updatedAt, "KR")} + ), + size: 100, + meta: { excelHeader: "최종수정일" }, + }, + + { + accessorKey: "updatedBy", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.updatedBy || '-'} + ), + size: 100, + meta: { excelHeader: "최종수정자" }, + }, + ] + }, + + // ░░░ 비고 ░░░ + { + accessorKey: "remarks", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.remarks || '-'} +
+ ), + size: 150, + meta: { excelHeader: "비고" }, + }, + + // ═══════════════════════════════════════════════════════════════ + // 액션 + // ═══════════════════════════════════════════════════════════════ + { + id: "actions", + header: "작업", + cell: ({ row }) => ( + + + + + + setRowAction({ row, type: "view" })}> + + 상세보기 + + setRowAction({ row, type: "edit" })}> + + 수정 + + + setRowAction({ row, type: "copy" })}> + + 복사 생성 + + setRowAction({ row, type: "manage_companies" })}> + + 참여업체 관리 + + + + ), + 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 +} + +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 ( +
+ {/* 신규 생성 */} + + + {/* 사전견적 요청 */} + {preQuoteEligibleBiddings.length > 0 && ( + + )} + + {/* 개찰 (입찰 오픈) */} + {openEligibleBiddings.length > 0 && ( + + )} + + {/* Export */} + + + + + + + + 입찰 목록 내보내기 + + + +
+ ) +} \ 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>, + Awaited> + ] + > +} + +export function BiddingsTable({ promises }: BiddingsTableProps) { + const [{ data, pageCount }, statusCounts] = React.use(promises) + const [isCompact, setIsCompact] = React.useState(false) + + const [rowAction, setRowAction] = React.useState | null>(null) + + const router = useRouter() + + const columns = React.useMemo( + () => getBiddingsColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { 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 ( + <> + + + + + + + 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("basic") + + // 사양설명회 정보 상태 + const [specMeetingInfo, setSpecMeetingInfo] = React.useState({ + meetingDate: "", + meetingTime: "", + location: "", + address: "", + contactPerson: "", + contactPhone: "", + contactEmail: "", + agenda: "", + materials: "", + notes: "", + isRequired: false, + meetingFiles: [], // 사양설명회 첨부파일 + }) + + // PR 아이템들 상태 + const [prItems, setPrItems] = React.useState([]) + + // 파일 첨부를 위해 선택된 아이템 ID + const [selectedItemForFile, setSelectedItemForFile] = React.useState(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({ + 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) => { + 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 ( + + + + + + {/* 고정 헤더 */} +
+ + 신규 입찰 생성 + + 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요. + + +
+ +
+ + {/* 탭 영역 */} +
+ +
+ + + 기본 정보 + {!tabValidation.basic.isValid && ( + + )} + + + 계약 정보 + {!tabValidation.contract.isValid && ( + + )} + + + 일정 & 회의 + {!tabValidation.schedule.isValid && ( + + )} + + 세부내역 + 담당자 & 기타 + +
+ +
+ {/* 기본 정보 탭 */} + + + + 기본 정보 + + + {/* 프로젝트 선택 */} + ( + + + 프로젝트 * + + + + + + + )} + /> + +
+ {/* 품목명 */} + ( + + + 품목명 * + + + + + + + )} + /> + + {/* 리비전 */} + ( + + 리비전 + + field.onChange(parseInt(e.target.value) || 0)} + /> + + + + )} + /> +
+ + {/* 입찰명 */} + ( + + + 입찰명 * + + + + + + + )} + /> + + {/* 설명 */} + ( + + 설명 + +