diff options
Diffstat (limited to 'lib')
25 files changed, 5719 insertions, 575 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> diff --git a/lib/forms/services.ts b/lib/forms/services.ts index cff23806..e2aa27ec 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -1227,7 +1227,7 @@ async function transformDataToSEDPFormat( LAST_REV_YN: true, CRTER_NO: designerNo, CHGER_NO: designerNo, - TYPE: packageCode, // Use packageCode instead of formCode + TYPE: formCode, // Use packageCode instead of formCode CLS_ID: tagClassCode, // Add CLS_ID with tagClass code PROJ_NO: projectNo, REV_NO: "00", @@ -1530,11 +1530,11 @@ export async function sendFormDataToSEDP( export async function deleteFormDataByTags({ formCode, contractItemId, - tagNos, + tagIdxs, }: { formCode: string contractItemId: number - tagNos: string[] + tagIdxs: string[] }): Promise<{ error?: string success?: boolean @@ -1543,13 +1543,13 @@ export async function deleteFormDataByTags({ }> { try { // 입력 검증 - if (!formCode || !contractItemId || !Array.isArray(tagNos) || tagNos.length === 0) { + if (!formCode || !contractItemId || !Array.isArray(tagIdxs) || TAG_IDX.length === 0) { return { - error: "Missing required parameters: formCode, contractItemId, tagNos", + error: "Missing required parameters: formCode, contractItemId, tagIdxs", } } - console.log(`[DELETE ACTION] Deleting tags for formCode: ${formCode}, contractItemId: ${contractItemId}, tagNos:`, tagNos) + console.log(`[DELETE ACTION] Deleting tags for formCode: ${formCode}, contractItemId: ${contractItemId}, tagNos:`, tagIdxs) // 트랜잭션으로 안전하게 처리 const result = await db.transaction(async (tx) => { @@ -1577,7 +1577,7 @@ export async function deleteFormDataByTags({ // 2. 삭제할 항목들 필터링 (formEntries에서) const updatedData = currentData.filter((item: any) => - !tagNos.includes(item.TAG_NO) + !tagIdxs.includes(item.TAG_IDX) ) const deletedFromFormEntries = currentData.length - updatedData.length @@ -1595,7 +1595,7 @@ export async function deleteFormDataByTags({ .where( and( eq(tags.contractItemId, contractItemId), - inArray(tags.tagNo, tagNos) + inArray(tags.tagIdx, tagIdxs) ) ) .returning({ tagNo: tags.tagNo }) diff --git a/lib/pq/helper.ts b/lib/pq/helper.ts index efd50714..81ee5db2 100644 --- a/lib/pq/helper.ts +++ b/lib/pq/helper.ts @@ -38,7 +38,6 @@ export function createPQFilterMapping(): CustomColumnMapping { // 실사 관련
evaluationResult: { table: vendorInvestigations, column: "evaluationResult" },
- evaluationType: { table: vendorInvestigations, column: "evaluationType" },
investigationStatus: { table: vendorInvestigations, column: "investigationStatus" },
investigationAddress: { table: vendorInvestigations, column: "investigationAddress" },
qmManagerId: { table: vendorInvestigations, column: "qmManagerId" },
@@ -88,7 +87,6 @@ export function createPQDirectColumnMapping(): CustomColumnMapping { // 실사 관련
evaluationResult: vendorInvestigations.evaluationResult,
- evaluationType: vendorInvestigations.evaluationType,
investigationStatus: vendorInvestigations.investigationStatus,
investigationAddress: vendorInvestigations.investigationAddress,
qmManagerId: vendorInvestigations.qmManagerId,
diff --git a/lib/pq/service.ts b/lib/pq/service.ts index ac1b9e87..ba0ce3c5 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -1869,7 +1869,6 @@ export async function getPQSubmissions(input: GetPQSubmissionsSchema) { .select({
id: vendorInvestigations.id,
investigationStatus: vendorInvestigations.investigationStatus,
- evaluationType: vendorInvestigations.evaluationType,
investigationAddress: vendorInvestigations.investigationAddress,
investigationMethod: vendorInvestigations.investigationMethod,
scheduledStartAt: vendorInvestigations.scheduledStartAt,
@@ -2398,7 +2397,6 @@ export async function rejectPQAction({ export async function requestInvestigationAction(
pqSubmissionIds: number[],
data: {
- evaluationType: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL",
qmManagerId: number,
forecastedAt: Date,
investigationAddress: string,
@@ -2441,7 +2439,6 @@ export async function requestInvestigationAction( vendorId: pq.vendorId,
pqSubmissionId: pq.id,
investigationStatus: "PLANNED" as const, // enum 타입으로 명시적 지정
- evaluationType: data.evaluationType,
qmManagerId: data.qmManagerId,
forecastedAt: data.forecastedAt,
investigationAddress: data.investigationAddress,
diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts index 5b1c500d..821fa372 100644 --- a/lib/sedp/get-form-tags.ts +++ b/lib/sedp/get-form-tags.ts @@ -26,7 +26,9 @@ interface Attribute { } interface TagEntry { + TAG_IDX: string; TAG_NO: string; + BF_TAG_NO: string; TAG_DESC: string; EP_ID: string; TAG_TYPE_ID: string; @@ -389,8 +391,8 @@ export async function importTagsFromSEDP( existingEntries.forEach(entry => { const data = entry.data as any[]; data.forEach(item => { - if (item.TAG_NO) { - existingTagMap.set(item.TAG_NO, { + if (item.TAG_IDX) { + existingTagMap.set(item.TAG_IDX, { entryId: entry.id, data: item }); @@ -399,7 +401,7 @@ export async function importTagsFromSEDP( }); existingTags.forEach(tag => { - existingTagsMap.set(tag.tagNo, tag); + existingTagsMap.set(tag.tagIdx, tag); }); // 진행 상황 보고 @@ -417,7 +419,7 @@ export async function importTagsFromSEDP( // SEDP 태그 데이터 처리 for (const tagEntry of tagEntries) { try { - if (!tagEntry.TAG_NO) { + if (!tagEntry.TAG_IDX) { excludedCount++; errors.push(`Missing TAG_NO in tag entry`); continue; @@ -459,6 +461,7 @@ export async function importTagsFromSEDP( // 기본 태그 데이터 객체 생성 (formEntries용) const tagObject: any = { + TAG_IDX: tagEntry.TAG_IDX, // SEDP 고유 식별자 TAG_NO: tagEntry.TAG_NO, TAG_DESC: tagEntry.TAG_DESC || "", VNDRCD:vendorRecord[0].vendorCode, @@ -470,6 +473,7 @@ export async function importTagsFromSEDP( const tagRecord = { contractItemId: packageId, formId: formId, + tagIdx: tagEntry.TAG_IDX, // SEDP 고유 식별자 tagNo: tagEntry.TAG_NO, tagType: tagTypeDescription || "", class: tagClassLabel, @@ -508,7 +512,7 @@ export async function importTagsFromSEDP( } // 기존 태그가 있는지 확인하고 처리 - const existingTag = existingTagMap.get(tagEntry.TAG_NO); + const existingTag = existingTagMap.get(tagEntry.TAG_IDX); if (existingTag) { // 기존 태그가 있으면 formEntries 업데이트 데이터 준비 @@ -516,7 +520,14 @@ export async function importTagsFromSEDP( let hasUpdates = false; for (const key of Object.keys(tagObject)) { - if (key === "TAG_NO") continue; + if (key === "TAG_IDX") continue; + + if (key === "TAG_NO" && tagObject[key] !== existingTag.data[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + continue; + } + if (key === "TAG_DESC" && tagObject[key] !== existingTag.data[key]) { updates[key] = tagObject[key]; @@ -542,7 +553,7 @@ export async function importTagsFromSEDP( if (hasUpdates) { updateData.push({ entryId: existingTag.entryId, - tagNo: tagEntry.TAG_NO, + tagIdx: tagEntry.TAG_IDX, // TAG_IDX로 변경 updates }); } @@ -557,7 +568,7 @@ export async function importTagsFromSEDP( processedCount++; } catch (error) { excludedCount++; - errors.push(`Error processing tag ${tagEntry.TAG_NO || 'unknown'}: ${error}`); + errors.push(`Error processing tag ${tagEntry.TAG_IDX || 'unknown'}: ${error}`); } } @@ -572,7 +583,7 @@ export async function importTagsFromSEDP( const data = entry.data as any[]; const updatedData = data.map(item => { - if (item.TAG_NO === update.tagNo) { + if (item.TAG_IDX === update.tagIdx) { return { ...item, ...update.updates }; } return item; @@ -627,6 +638,12 @@ export async function importTagsFromSEDP( // 기존 태그가 있으면 업데이트 준비 const tagUpdates: any = {}; let hasTagUpdates = false; + + // tagNo도 업데이트 가능 (편집된 경우) + if (existingTagRecord.tagNo !== tagRecord.tagNo) { + tagUpdates.tagNo = tagRecord.tagNo; + hasTagUpdates = true; + } if (existingTagRecord.tagType !== tagRecord.tagType) { tagUpdates.tagType = tagRecord.tagType; @@ -663,7 +680,7 @@ export async function importTagsFromSEDP( await tx.insert(tags) .values(newTagRecords) .onConflictDoNothing({ - target: [tags.contractItemId, tags.tagNo] + target: [tags.contractItemId, tags.tagIdx] }); } catch (error) { // 개별 삽입으로 재시도 @@ -672,10 +689,10 @@ export async function importTagsFromSEDP( await tx.insert(tags) .values(tagRecord) .onConflictDoNothing({ - target: [tags.contractItemId, tags.tagNo] + target: [tags.contractItemId, tags.tagIdx] }); } catch (individualError) { - errors.push(`Error inserting tag ${tagRecord.tagNo}: ${individualError}`); + errors.push(`Error inserting tag ${tagRecord.tagIdx}: ${individualError}`); } } } diff --git a/lib/sedp/get-tags.ts b/lib/sedp/get-tags.ts index cb549d8c..ff742299 100644 --- a/lib/sedp/get-tags.ts +++ b/lib/sedp/get-tags.ts @@ -18,6 +18,7 @@ import { getSEDPToken } from "./sedp-token"; /** * 태그 가져오기 서비스 함수 * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장 + * TAG_IDX를 기준으로 태그를 식별합니다. * * @param packageId 계약 아이템 ID (contractItemId) * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수 @@ -321,7 +322,8 @@ export async function importTagsFromSEDP( // formEntries를 위한 데이터 수집 const newTagsForFormEntry: Array<{ - TAG_NO: string; + TAG_IDX: string; // 변경: TAG_NO → TAG_IDX + TAG_NO?: string; // TAG_NO도 함께 저장 (편집 가능한 필드) TAG_DESC: string | null; status: string; [key: string]: any; @@ -332,6 +334,21 @@ export async function importTagsFromSEDP( try { const entry = tagEntries[i]; + // TAG_IDX가 없는 경우 제외 (변경: TAG_NO → TAG_IDX 체크) + if (!entry.TAG_IDX) { + excludedCount++; + totalExcludedCount++; + + // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트) + if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { + const baseProgress = 60; + const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount))); + progressCallback(baseProgress + entryProgress); + } + + continue; // 이 항목은 건너뜀 + } + // TAG_TYPE_ID가 null이거나 빈 문자열인 경우 제외 if (entry.TAG_TYPE_ID === null || entry.TAG_TYPE_ID === "") { excludedCount++; @@ -363,19 +380,21 @@ export async function importTagsFromSEDP( ) }); - // Insert or update the tag + // Insert or update the tag - tagIdx 필드 추가 await db.insert(tags).values({ contractItemId: packageId, formId: formId, - tagNo: entry.TAG_NO, + tagIdx: entry.TAG_IDX, // 추가: SEDP 고유 식별자 + tagNo: entry.TAG_NO || entry.TAG_IDX, // TAG_NO가 없으면 TAG_IDX 사용 tagType: tagType?.description || entry.TAG_TYPE_ID, tagClassId: tagClass?.id, class: tagClass?.label || entry.CLS_ID, description: entry.TAG_DESC }).onConflictDoUpdate({ - target: [tags.contractItemId, tags.tagNo], + target: [tags.contractItemId, tags.tagIdx], // 변경: tagNo → tagIdx set: { formId: formId, + tagNo: entry.TAG_NO || entry.TAG_IDX, // tagNo도 업데이트 가능 tagType: tagType?.description || entry.TAG_TYPE_ID, class: tagClass?.label || entry.CLS_ID, description: entry.TAG_DESC, @@ -385,9 +404,10 @@ export async function importTagsFromSEDP( // formEntries용 데이터 수집 const tagDataForFormEntry = { - TAG_NO: entry.TAG_NO, + TAG_IDX: entry.TAG_IDX, // 변경: TAG_NO → TAG_IDX + TAG_NO: entry.TAG_NO || entry.TAG_IDX, // TAG_NO도 함께 저장 TAG_DESC: entry.TAG_DESC || null, - status:"From S-EDP" // SEDP에서 가져온 데이터임을 표시 + status: "From S-EDP" // SEDP에서 가져온 데이터임을 표시 }; // ATTRIBUTES가 있으면 추가 (SHI 필드들) @@ -416,7 +436,7 @@ export async function importTagsFromSEDP( } } - // Step 7: formEntries 업데이트 + // Step 7: formEntries 업데이트 - TAG_IDX 기준으로 변경 if (newTagsForFormEntry.length > 0) { try { // 기존 formEntry 가져오기 @@ -430,7 +450,8 @@ export async function importTagsFromSEDP( if (existingEntry && existingEntry.id) { // 기존 formEntry가 있는 경우 let existingData: Array<{ - TAG_NO: string; + TAG_IDX?: string; // 추가: TAG_IDX 필드 + TAG_NO?: string; TAG_DESC?: string; status?: string; [key: string]: any; @@ -440,18 +461,22 @@ export async function importTagsFromSEDP( existingData = existingEntry.data; } - // 기존 TAG_NO들 추출 - const existingTagNos = new Set(existingData.map(item => item.TAG_NO)); + // 기존 TAG_IDX들 추출 (변경: TAG_NO → TAG_IDX) + const existingTagIdxs = new Set( + existingData + .map(item => item.TAG_IDX) + .filter(tagIdx => tagIdx !== undefined && tagIdx !== null) + ); - // 중복되지 않은 새 태그들만 필터링 + // 중복되지 않은 새 태그들만 필터링 (변경: TAG_NO → TAG_IDX) const newUniqueTagsData = newTagsForFormEntry.filter( - tagData => !existingTagNos.has(tagData.TAG_NO) + tagData => !existingTagIdxs.has(tagData.TAG_IDX) ); - // 기존 태그들의 status와 ATTRIBUTES 업데이트 + // 기존 태그들의 status와 ATTRIBUTES 업데이트 (변경: TAG_NO → TAG_IDX) const updatedExistingData = existingData.map(existingItem => { const newTagData = newTagsForFormEntry.find( - newItem => newItem.TAG_NO === existingItem.TAG_NO + newItem => newItem.TAG_IDX === existingItem.TAG_IDX ); if (newTagData) { @@ -459,7 +484,7 @@ export async function importTagsFromSEDP( return { ...existingItem, ...newTagData, - TAG_NO: existingItem.TAG_NO // TAG_NO는 유지 + TAG_IDX: existingItem.TAG_IDX // TAG_IDX는 유지 }; } diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts index 559c09a2..6ae2e675 100644 --- a/lib/sedp/sync-form.ts +++ b/lib/sedp/sync-form.ts @@ -129,6 +129,7 @@ interface newRegister { REG_TYPE_ID: string; TOOL_ID: string; TOOL_TYPE: string; + SCOPES: string[]; MAP_CLS: { TOOL_ATT_NAME: string; ITEMS: ClassItmes[]; @@ -933,10 +934,15 @@ export async function saveFormMappingsAndMetas( const defaultAttributes = await getDefaulTAttributes(); /* ------------------------------------------------------------------ */ - /* 2. Contract‑item look‑up (TOOL_TYPE) - 수정된 부분 */ + /* 2. Contract‑item look‑up (SCOPES) - 수정된 부분 */ /* ------------------------------------------------------------------ */ - const uniqueItemCodes = [...new Set(newRegisters.filter(nr => nr.TOOL_TYPE).map(nr => nr.TOOL_TYPE as string))]; - const itemCodeToContractItemIds = await getContractItemsByItemCodes(uniqueItemCodes, projectId, projectCode); + // SCOPES 배열에서 모든 unique한 itemCode들을 추출 + const uniqueItemCodes = [...new Set( + newRegisters + .filter(nr => nr.SCOPES && nr.SCOPES.length > 0) + .flatMap(nr => nr.SCOPES as string[]) + )]; + const itemCodeToContractItemIds = await getContractItemsByItemCodes(uniqueItemCodes, projectId); /* ------------------------------------------------------------------ */ /* 3. Buffers for bulk insert */ @@ -1019,27 +1025,42 @@ export async function saveFormMappingsAndMetas( if (!cls) { console.warn(`클래스 ${classId} 없음`); return; } const tp = tagTypeMap.get(cls.tagTypeCode); if (!tp) { console.warn(`태그 타입 ${cls.tagTypeCode} 없음`); return; } - mappingsToSave.push({ projectId, tagTypeLabel: tp.description, classLabel: cls.label, formCode, formName: legacy?.DESC || formCode, remark: newReg.TOOL_TYPE || null, ep: newReg.EP_ID || legacy?.EP_ID || "", createdAt: new Date(), updatedAt: new Date() }); + // SCOPES 배열을 문자열로 변환하여 remark에 저장 + const scopesRemark = newReg.SCOPES && newReg.SCOPES.length > 0 ? newReg.SCOPES.join(', ') : null; + mappingsToSave.push({ + projectId, + tagTypeLabel: tp.description, + classLabel: cls.label, + formCode, + formName: legacy?.DESC || formCode, + remark: scopesRemark, + ep: newReg.EP_ID || legacy?.EP_ID || "", + createdAt: new Date(), + updatedAt: new Date() + }); }); /* ---------- 4‑d. contractItem ↔ form - 수정된 부분 -------------- */ - if (newReg.TOOL_TYPE) { - const contractItemIds = itemCodeToContractItemIds.get(newReg.TOOL_TYPE); - if (contractItemIds && contractItemIds.length > 0) { - // 모든 contractItemId에 대해 form 생성 - contractItemIds.forEach(cId => { - contractItemIdsWithForms.add(cId); - formsToSave.push({ - contractItemId: cId, - formCode, - formName: legacy?.DESC || formCode, - eng: true, - createdAt: new Date(), - updatedAt: new Date() + if (newReg.SCOPES && newReg.SCOPES.length > 0) { + // SCOPES 배열의 각 itemCode에 대해 처리 + for (const itemCode of newReg.SCOPES) { + const contractItemIds = itemCodeToContractItemIds.get(itemCode); + if (contractItemIds && contractItemIds.length > 0) { + // 모든 contractItemId에 대해 form 생성 + contractItemIds.forEach(cId => { + contractItemIdsWithForms.add(cId); + formsToSave.push({ + contractItemId: cId, + formCode, + formName: legacy?.DESC || formCode, + eng: true, + createdAt: new Date(), + updatedAt: new Date() + }); }); - }); - } else { - console.warn(`itemCode ${newReg.TOOL_TYPE} 의 contractItemId 없음`); + } else { + console.warn(`itemCode ${itemCode} 의 contractItemId 없음`); + } } } } diff --git a/lib/tags/service.ts b/lib/tags/service.ts index a1dff137..bec342e1 100644 --- a/lib/tags/service.ts +++ b/lib/tags/service.ts @@ -14,7 +14,7 @@ import { getFormMappingsByTagType } from './form-mapping-service'; import { contractItems, contracts } from "@/db/schema/contract"; import { getCodeListsByID } from "../sedp/sync-object-class"; import { projects } from "@/db/schema"; - +import { randomBytes } from 'crypto'; // 폼 결과를 위한 인터페이스 정의 interface CreatedOrExistingForm { @@ -24,6 +24,14 @@ interface CreatedOrExistingForm { isNewlyCreated: boolean; } +/** + * 16진수 24자리 고유 식별자 생성 + * @returns 24자리 16진수 문자열 (예: "a1b2c3d4e5f6789012345678") + */ +function generateTagIdx(): string { + return randomBytes(12).toString('hex'); // 12바이트 = 24자리 16진수 +} + export async function getTags(input: GetTagsSchema, packagesId: number) { // return unstable_cache( @@ -90,6 +98,7 @@ export async function getTags(input: GetTagsSchema, packagesId: number) { // )(); } + export async function createTag( formData: CreateTagSchema, selectedPackageId: number | null @@ -259,10 +268,15 @@ export async function createTag( } } - // 5) 새 Tag 생성 (같은 트랜잭션 `tx` 사용) + // 🆕 16진수 24자리 태그 고유 식별자 생성 + const generatedTagIdx = generateTagIdx(); + console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`); + + // 5) 새 Tag 생성 (tagIdx 추가) const [newTag] = await insertTag(tx, { contractItemId: selectedPackageId, formId: primaryFormId, + tagIdx: generatedTagIdx, // 🆕 생성된 16진수 24자리 추가 tagNo: validated.data.tagNo, class: validated.data.class, tagType: validated.data.tagType, @@ -271,7 +285,7 @@ export async function createTag( console.log(`tags-${selectedPackageId}`, "create", newTag) - // 6) 생성된 각 form에 대해 formEntries에 데이터 추가 + // 6) 생성된 각 form에 대해 formEntries에 데이터 추가 (TAG_IDX 포함) for (const form of createdOrExistingForms) { try { // 기존 formEntry 가져오기 @@ -282,16 +296,18 @@ export async function createTag( ) }); - // 새로운 태그 데이터 객체 생성 + // 새로운 태그 데이터 객체 생성 (TAG_IDX 포함) const newTagData = { + TAG_IDX: generatedTagIdx, // 🆕 같은 16진수 24자리 값 사용 TAG_NO: validated.data.tagNo, TAG_DESC: validated.data.description ?? null, status: "New" // 수동으로 생성된 태그임을 표시 }; if (existingEntry && existingEntry.id) { - // 기존 formEntry가 있는 경우 + // 기존 formEntry가 있는 경우 - TAG_IDX 타입 추가 let existingData: Array<{ + TAG_IDX?: string; // 🆕 TAG_IDX 필드 추가 TAG_NO: string; TAG_DESC?: string; status?: string; @@ -302,13 +318,14 @@ export async function createTag( existingData = existingEntry.data; } - // TAG_NO가 이미 존재하는지 확인 + // TAG_IDX 또는 TAG_NO가 이미 존재하는지 확인 (우선순위: TAG_IDX) const existingTagIndex = existingData.findIndex( - item => item.TAG_NO === validated.data.tagNo + item => item.TAG_IDX === generatedTagIdx || + (item.TAG_NO === validated.data.tagNo && !item.TAG_IDX) ); if (existingTagIndex === -1) { - // TAG_NO가 없으면 새로 추가 + // 태그가 없으면 새로 추가 const updatedData = [...existingData, newTagData]; await tx @@ -319,12 +336,12 @@ export async function createTag( }) .where(eq(formEntries.id, existingEntry.id)); - console.log(`[CREATE TAG] Added tag ${validated.data.tagNo} to existing formEntry for form ${form.formCode}`); + console.log(`[CREATE TAG] Added tag ${validated.data.tagNo} with tagIdx ${generatedTagIdx} to existing formEntry for form ${form.formCode}`); } else { console.log(`[CREATE TAG] Tag ${validated.data.tagNo} already exists in formEntry for form ${form.formCode}`); } } else { - // formEntry가 없는 경우 새로 생성 + // formEntry가 없는 경우 새로 생성 (TAG_IDX 포함) await tx.insert(formEntries).values({ formCode: form.formCode, contractItemId: selectedPackageId, @@ -333,7 +350,7 @@ export async function createTag( updatedAt: new Date(), }); - console.log(`[CREATE TAG] Created new formEntry with tag ${validated.data.tagNo} for form ${form.formCode}`); + console.log(`[CREATE TAG] Created new formEntry with tag ${validated.data.tagNo} and tagIdx ${generatedTagIdx} for form ${form.formCode}`); } } catch (formEntryError) { console.error(`[CREATE TAG] Error updating formEntry for form ${form.formCode}:`, formEntryError); @@ -351,13 +368,14 @@ export async function createTag( revalidateTag(`form-data-${form.formCode}-${selectedPackageId}`) }) - // 8) 성공 시 반환 + // 8) 성공 시 반환 (tagIdx 추가) return { success: true, data: { forms: createdOrExistingForms, primaryFormId, - tagNo: validated.data.tagNo + tagNo: validated.data.tagNo, + tagIdx: generatedTagIdx, // 🆕 생성된 tagIdx도 반환 }, } }) @@ -369,7 +387,6 @@ export async function createTag( } } - export async function createTagInForm( formData: CreateTagSchema, selectedPackageId: number | null, @@ -499,10 +516,15 @@ export async function createTagInForm( } if (form?.id) { - // 5) 새 Tag 생성 (같은 트랜잭션 `tx` 사용) + // 🆕 16진수 24자리 태그 고유 식별자 생성 + const generatedTagIdx = generateTagIdx(); + console.log(`[CREATE TAG IN FORM] Generated tagIdx: ${generatedTagIdx}`); + + // 5) 새 Tag 생성 (tagIdx 추가) const [newTag] = await insertTag(tx, { contractItemId: selectedPackageId, formId: form.id, + tagIdx: generatedTagIdx, // 🆕 생성된 16진수 24자리 추가 tagNo: validated.data.tagNo, class: validated.data.class, tagType: validated.data.tagType, @@ -518,8 +540,9 @@ export async function createTagInForm( }); if (entry && entry.id) { - // 7) 기존 데이터 가져오기 (배열인지 확인) + // 7) 기존 데이터 가져오기 (배열인지 확인) - TAG_IDX 타입 추가 let existingData: Array<{ + TAG_IDX?: string; // 🆕 TAG_IDX 필드 추가 TAG_NO: string; TAG_DESC?: string; status?: string; @@ -532,8 +555,9 @@ export async function createTagInForm( console.log(`[CREATE TAG IN FORM] Existing data count: ${existingData.length}`); - // 8) 새로운 태그를 기존 데이터에 추가 (status 필드 포함) + // 8) 새로운 태그를 기존 데이터에 추가 (TAG_IDX 포함) const newTagData = { + TAG_IDX: generatedTagIdx, // 🆕 같은 16진수 24자리 값 사용 TAG_NO: validated.data.tagNo, TAG_DESC: validated.data.description ?? null, status: "New" // 수동으로 생성된 태그임을 표시 @@ -542,7 +566,7 @@ export async function createTagInForm( const updatedData = [...existingData, newTagData]; console.log(`[CREATE TAG IN FORM] Updated data count: ${updatedData.length}`); - console.log(`[CREATE TAG IN FORM] Added tag: ${validated.data.tagNo} with status: 수동 생성`); + console.log(`[CREATE TAG IN FORM] Added tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}, status: 수동 생성`); // 9) formEntries 업데이트 await tx @@ -553,10 +577,11 @@ export async function createTagInForm( }) .where(eq(formEntries.id, entry.id)); } else { - // 10) formEntry가 없는 경우 새로 생성 (status 필드 포함) + // 10) formEntry가 없는 경우 새로 생성 (TAG_IDX 포함) console.log(`[CREATE TAG IN FORM] No existing formEntry found, creating new one`); const newEntryData = [{ + TAG_IDX: generatedTagIdx, // 🆕 같은 16진수 24자리 값 사용 TAG_NO: validated.data.tagNo, TAG_DESC: validated.data.description ?? null, status: "New" // 수동으로 생성된 태그임을 표시 @@ -571,7 +596,7 @@ export async function createTagInForm( }); } - console.log(`[CREATE TAG IN FORM] Successfully created tag: ${validated.data.tagNo}`) + console.log(`[CREATE TAG IN FORM] Successfully created tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}`) } else { return { error: "Failed to create or find form" }; } @@ -588,6 +613,7 @@ export async function createTagInForm( data: { formId: form.id, tagNo: validated.data.tagNo, + tagIdx: generatedTagIdx, // 🆕 생성된 tagIdx도 반환 formCreated: !form // form이 새로 생성되었는지 여부 } } diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx index 10167c62..e5207cd8 100644 --- a/lib/tags/table/add-tag-dialog.tsx +++ b/lib/tags/table/add-tag-dialog.tsx @@ -61,6 +61,7 @@ import { type ClassOption, TagTypeOption, } from "@/lib/tags/service" +import { ScrollArea } from "@/components/ui/scroll-area" // Updated to support multiple rows and subclass interface MultiTagFormValues { @@ -96,7 +97,7 @@ interface UpdatedClassOption extends ClassOption { } interface AddTagDialogProps { - selectedPackageId: number + selectedPackageId: number } export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { @@ -171,7 +172,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { setIsLoadingSubFields(true) try { // 수정된 getSubfieldsByTagType 함수 호출 (subclassRemark 파라미터 추가) - const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId, subclassRemark ,subclass ) + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId, subclassRemark, subclass) const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ name: field.name, label: field.label, @@ -213,7 +214,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { form.setValue("subclass", "") // 서브클래스 초기화 setSelectedClassOption(classOption) setSelectedSubclass("") - + if (classOption.tagTypeCode) { setSelectedTagTypeCode(classOption.tagTypeCode) // If you have tagTypeList, you can find the label @@ -223,13 +224,13 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { } else if (classOption.tagTypeDescription) { form.setValue("tagType", classOption.tagTypeDescription) } - + // 서브클래스가 있으면 서브필드 로딩을 하지 않고 대기 if (classOption.subclasses && classOption.subclasses.length > 0) { setSubFields([]) // 서브클래스 선택을 기다림 } else { // 서브클래스가 없으면 바로 서브필드 로딩 - await loadFilteredSubFieldsByTagTypeCode(classOption.tagTypeCode, "","") + await loadFilteredSubFieldsByTagTypeCode(classOption.tagTypeCode, "", "") } } } @@ -239,13 +240,13 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { // --------------- async function handleSelectSubclass(subclassCode: string) { if (!selectedClassOption || !selectedTagTypeCode) return - + setSelectedSubclass(subclassCode) form.setValue("subclass", subclassCode) - + // 선택된 서브클래스의 리마크 값 가져오기 const subclassRemarkValue = selectedClassOption.subclassRemark[subclassCode] || "" - + // 리마크 값으로 필터링된 서브필드 로드 await loadFilteredSubFieldsByTagTypeCode(selectedTagTypeCode, subclassRemarkValue, subclassCode) } @@ -257,28 +258,28 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { if (subFields.length === 0) { return; } - + const subscription = form.watch((value) => { if (!value.rows || subFields.length === 0) { return; } - + const rows = [...value.rows]; rows.forEach((row, rowIndex) => { if (!row) return; - + let combined = ""; subFields.forEach((sf, idx) => { const fieldValue = row[sf.name] || ""; - + // delimiter를 앞에 붙이기 (첫 번째 필드가 아니고, 현재 필드에 값이 있고, delimiter가 있는 경우) if (idx > 0 && fieldValue && sf.delimiter) { combined += sf.delimiter; } - + combined += fieldValue; }); - + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); if (currentTagNo !== combined) { form.setValue(`rows.${rowIndex}.tagNo`, combined, { @@ -289,7 +290,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { } }); }); - + return () => subscription.unsubscribe(); }, [subFields, form]); @@ -340,7 +341,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { try { const res = await createTag(tagData, selectedPackageId); if ("error" in res) { - console.log(res.error ) + console.log(res.error) failedTags.push({ tag: row.tagNo, error: res.error }); } else { successfulTags.push(row.tagNo); @@ -468,7 +469,12 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { value={classSearchTerm} onValueChange={setClassSearchTerm} /> - <CommandList key={`${commandId}-list`} className="max-h-[300px]"> + + <CommandList key={`${commandId}-list`} className="max-h-[300px]" onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }}> <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</CommandEmpty> <CommandGroup key={`${commandId}-group`}> {classOptions.map((opt, optIndex) => { @@ -519,7 +525,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { // --------------- function renderSubclassField(field: any) { const hasSubclasses = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0 - + if (!hasSubclasses) { return null } @@ -542,7 +548,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { <SelectContent> {selectedClassOption?.subclasses.map((subclass) => ( <SelectItem key={subclass.id} value={subclass.id}> - {subclass.desc} + {subclass.desc} </SelectItem> ))} </SelectContent> @@ -613,7 +619,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { const message = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0 ? "서브클래스를 선택해주세요." : "이 태그 유형에 대한 필드가 없습니다." - + return ( <div className="py-4 text-center text-muted-foreground"> {message} diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts index 28fad74b..d2cec15d 100644 --- a/lib/vendor-document-list/enhanced-document-service.ts +++ b/lib/vendor-document-list/enhanced-document-service.ts @@ -1027,11 +1027,11 @@ export async function getDocumentDetails(documentId: number) { // 2. 해당 벤더의 모든 계약 ID들 조회 const vendorContracts = await db - .select({ projectId: contracts.projectId }) + .select({ projectId: contracts.projectId, contractId:contracts.id }) .from(contracts) .where(eq(contracts.vendorId, companyId)) - const contractIds = vendorContracts.map(c => c.projectId) + const contractIds = vendorContracts.map(c => c.contractId) if (contractIds.length === 0) { return { data: [], pageCount: 0, total: 0, drawingKind: null, vendorInfo: null } @@ -1057,7 +1057,7 @@ export async function getDocumentDetails(documentId: number) { // 5. 최종 WHERE 조건 (계약 ID들로 필터링) const finalWhere = and( - inArray(simplifiedDocumentsView.projectId, contractIds), + inArray(simplifiedDocumentsView.contractId, contractIds), advancedWhere, globalWhere, ) @@ -1099,9 +1099,8 @@ export async function getDocumentDetails(documentId: number) { vendorName: vendors.vendorName, vendorCode: vendors.vendorCode, }) - .from(contracts) - .leftJoin(vendors, eq(contracts.vendorId, vendors.id)) - .where(eq(contracts.projectId, contractIds[0])) + .from(vendors) + .where(eq(vendors.id, companyId)) .limit(1) return { data, total, drawingKind, vendorInfo } diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 726ea101..3811e668 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -32,7 +32,7 @@ import { } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" import { DocumentStagesOnlyView } from "@/db/schema" -import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash} from "lucide-react" +import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle} from "lucide-react" import { toast } from "sonner" import { getDocumentNumberTypes, @@ -43,7 +43,8 @@ import { createDocument, updateDocument, deleteDocuments, - updateStage + updateStage, + getDocumentClassOptionsByContract } from "./document-stages-service" import { type Row } from "@tanstack/react-table" @@ -59,6 +60,32 @@ import { DrawerTrigger, } from "@/components/ui/drawer" import { useRouter } from "next/navigation" +import { cn, formatDate } from "@/lib/utils" +import ExcelJS from 'exceljs' +import { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" + +const getStatusVariant = (status: string) => { + switch (status) { + case 'COMPLETED': return 'success' + case 'IN_PROGRESS': return 'default' + case 'SUBMITTED': return 'secondary' + case 'REJECTED': return 'destructive' + default: return 'outline' + } +} + +const getStatusText = (status: string) => { + switch (status) { + case 'PLANNED': return '계획' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출' + case 'COMPLETED': return '완료' + case 'REJECTED': return '반려' + default: return status + } +} + // ============================================================================= // 1. Add Document Dialog @@ -83,6 +110,10 @@ export function AddDocumentDialog({ const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([]) + + console.log(documentNumberTypes, "documentNumberTypes") + console.log(documentClassOptions, "documentClassOptions") + const [formData, setFormData] = React.useState({ documentNumberTypeId: "", documentClassId: "", @@ -103,8 +134,8 @@ export function AddDocumentDialog({ setIsLoading(true) try { const [typesResult, classesResult] = await Promise.all([ - getDocumentNumberTypes(), - getDocumentClasses() + getDocumentNumberTypes(contractId), + getDocumentClasses(contractId) ]) if (typesResult.success) { @@ -137,8 +168,8 @@ export function AddDocumentDialog({ const comboBoxPromises = configsResult.data .filter(config => config.codeGroup?.controlType === 'combobox') .map(async (config) => { - const optionsResult = await getComboBoxOptions(config.codeGroupId!) - return { + const optionsResult = await getComboBoxOptions(config.codeGroupId!, contractId) + return { codeGroupId: config.codeGroupId, options: optionsResult.success ? optionsResult.data : [] } @@ -328,7 +359,7 @@ export function AddDocumentDialog({ <SelectContent> {documentNumberTypes.map((type) => ( <SelectItem key={type.id} value={String(type.id)}> - {type.name} - {type.description} + {type.name} </SelectItem> ))} </SelectContent> @@ -408,7 +439,7 @@ export function AddDocumentDialog({ <SelectContent> {documentClasses.map((cls) => ( <SelectItem key={cls.id} value={String(cls.id)}> - {cls.code} - {cls.description} + {cls.value} </SelectItem> ))} </SelectContent> @@ -496,7 +527,7 @@ export function AddDocumentDialog({ } // ============================================================================= -// 2. Edit Document Dialog +// Edit Document Dialog (with improved stage plan date editing) // ============================================================================= interface EditDocumentDialogProps { open: boolean @@ -591,7 +622,7 @@ export function EditDocumentDialog({ return ( <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="sm:max-w-[600px] h-full flex flex-col"> + <SheetContent className="sm:max-w-[500px] h-full flex flex-col"> <SheetHeader className="flex-shrink-0"> <SheetTitle>Edit Document</SheetTitle> <SheetDescription> @@ -644,28 +675,54 @@ export function EditDocumentDialog({ <div className="grid gap-3"> {document.allStages .sort((a, b) => (a.stageOrder || 0) - (b.stageOrder || 0)) - .map((stage) => ( - <div key={stage.id} className="grid grid-cols-2 gap-3 items-center"> - <div> - <Label className="text-sm font-medium"> - {stage.stageName} - </Label> - <p className="text-xs text-gray-500"> - Status: {stage.stageStatus} - {stage.actualDate && ` | Completed: ${stage.actualDate}`} - </p> - </div> - <div className="grid gap-1"> - <Label className="text-xs text-gray-600">Plan Date</Label> - <Input - type="date" - value={formData.stagePlanDates[stage.id] || ""} - onChange={(e) => handleStagePlanDateChange(stage.id, e.target.value)} - className="text-sm" - /> - </div> - </div> - ))} + .map((stage) => { + const canEditPlanDate = stage.stageStatus === 'PLANNED' + return ( + <div key={stage.id} className="grid grid-cols-2 gap-3 items-center"> + <div> + <Label className="text-sm font-medium"> + {stage.stageName} + </Label> + <div className="flex items-center gap-2 mt-1"> + <Badge + variant={getStatusVariant(stage.stageStatus)} + className="text-xs" + > + {getStatusText(stage.stageStatus)} + </Badge> + {!canEditPlanDate && ( + <Badge variant="outline" className="text-xs"> + 편집 불가 + </Badge> + )} + </div> + {stage.actualDate && ( + <p className="text-xs text-gray-500 mt-1"> + 완료일: {formatDate(stage.actualDate)} + </p> + )} + </div> + <div className="grid gap-1"> + <Label className="text-xs text-gray-600">Plan Date</Label> + <Input + type="date" + value={formData.stagePlanDates[stage.id] || ""} + onChange={(e) => handleStagePlanDateChange(stage.id, e.target.value)} + disabled={!canEditPlanDate} + className={cn( + "text-sm", + !canEditPlanDate && "bg-gray-100 cursor-not-allowed" + )} + /> + {!canEditPlanDate && ( + <p className="text-xs text-gray-500"> + 계획 상태일 때만 수정 가능 + </p> + )} + </div> + </div> + ) + })} </div> </div> )} @@ -689,6 +746,10 @@ export function EditDocumentDialog({ // ============================================================================= // 3. Edit Stage Dialog // ============================================================================= + +// ============================================================================= +// Improved Edit Stage Dialog +// ============================================================================= interface EditStageDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -704,33 +765,31 @@ export function EditStageDialog({ }: EditStageDialogProps) { const [isLoading, setIsLoading] = React.useState(false) const [formData, setFormData] = React.useState({ - stageName: "", planDate: "", - actualDate: "", - stageStatus: "PLANNED", - assigneeName: "", - priority: "MEDIUM", notes: "" }) - // Load stage information by stageId - React.useEffect(() => { + // 현재 스테이지 정보 + const currentStage = React.useMemo(() => { if (document && stageId) { - const stage = document.allStages?.find(s => s.id === stageId) - if (stage) { - setFormData({ - stageName: stage.stageName || "", - planDate: stage.planDate || "", - actualDate: stage.actualDate || "", - stageStatus: stage.stageStatus || "PLANNED", - assigneeName: stage.assigneeName || "", - priority: stage.priority || "MEDIUM", - notes: stage.notes || "" - }) - } + return document.allStages?.find(s => s.id === stageId) || null } + return null }, [document, stageId]) + // Load stage information by stageId + React.useEffect(() => { + if (currentStage) { + setFormData({ + planDate: currentStage.planDate || "", + notes: currentStage.notes || "" + }) + } + }, [currentStage]) + + // 계획날짜 편집 가능 여부 확인 + const canEditPlanDate = currentStage?.stageStatus === 'PLANNED' + const handleSubmit = async () => { if (!stageId) return @@ -738,147 +797,137 @@ export function EditStageDialog({ try { const result = await updateStage({ stageId, - ...formData + planDate: formData.planDate, + notes: formData.notes }) if (result.success) { - toast.success("Stage updated successfully.") + toast.success("스테이지가 성공적으로 업데이트되었습니다.") onOpenChange(false) } else { - toast.error(result.error || "Error updating stage.") + toast.error(result.error || "스테이지 업데이트 중 오류가 발생했습니다.") } } catch (error) { - toast.error("Error updating stage.") + toast.error("스테이지 업데이트 중 오류가 발생했습니다.") } finally { setIsLoading(false) } } + if (!currentStage) { + return null + } + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[500px] h-[70vh] flex flex-col"> - <DialogHeader className="flex-shrink-0"> - <DialogTitle>Edit Stage</DialogTitle> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>스테이지 편집</DialogTitle> <DialogDescription> - You can modify stage information. + 스테이지의 계획 날짜와 노트를 수정할 수 있습니다. </DialogDescription> </DialogHeader> - <div className="flex-1 overflow-y-auto pr-2"> - <div className="grid gap-4 py-4"> - <div className="grid gap-2"> - <Label htmlFor="edit-stageName">Stage Name</Label> - <div className="p-2 bg-gray-100 rounded text-sm"> - {formData.stageName} - </div> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div className="grid gap-2"> - <Label htmlFor="edit-planDate"> - <Calendar className="inline w-4 h-4 mr-1" /> - Plan Date - </Label> - <Input - id="edit-planDate" - type="date" - value={formData.planDate} - onChange={(e) => setFormData({ ...formData, planDate: e.target.value })} - /> - </div> - <div className="grid gap-2"> - <Label htmlFor="edit-actualDate"> - <Calendar className="inline w-4 h-4 mr-1" /> - Actual Date - </Label> - <Input - id="edit-actualDate" - type="date" - value={formData.actualDate} - onChange={(e) => setFormData({ ...formData, actualDate: e.target.value })} - /> + <div className="grid gap-4 py-4"> + {/* 참조 정보 섹션 */} + <div className="border rounded-lg p-3 bg-gray-50"> + <Label className="text-sm font-medium text-gray-700 mb-2 block"> + 스테이지 정보 (참조용) + </Label> + + <div className="grid grid-cols-2 gap-3 text-sm"> + <div> + <Label className="text-xs text-gray-600">스테이지명</Label> + <div className="font-medium">{currentStage.stageName}</div> </div> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div className="grid gap-2"> - <Label htmlFor="edit-stageStatus">Status</Label> - <Select - value={formData.stageStatus} - onValueChange={(value) => setFormData({ ...formData, stageStatus: value })} + + <div> + <Label className="text-xs text-gray-600">현재 상태</Label> + <Badge + variant={getStatusVariant(currentStage.stageStatus)} + className="text-xs" > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="PLANNED">Planned</SelectItem> - <SelectItem value="IN_PROGRESS">In Progress</SelectItem> - <SelectItem value="SUBMITTED">Submitted</SelectItem> - <SelectItem value="COMPLETED">Completed</SelectItem> - </SelectContent> - </Select> + {getStatusText(currentStage.stageStatus)} + </Badge> </div> - <div className="grid gap-2"> - <Label htmlFor="edit-priority"> - <Target className="inline w-4 h-4 mr-1" /> - Priority - </Label> - <Select - value={formData.priority} - onValueChange={(value) => setFormData({ ...formData, priority: value })} - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="HIGH">High</SelectItem> - <SelectItem value="MEDIUM">Medium</SelectItem> - <SelectItem value="LOW">Low</SelectItem> - </SelectContent> - </Select> + + <div> + <Label className="text-xs text-gray-600">담당자</Label> + <div>{currentStage.assigneeName || "미지정"}</div> </div> + + <div> + <Label className="text-xs text-gray-600">우선순위</Label> + <div>{currentStage.priority || "MEDIUM"}</div> + </div> + + {currentStage.actualDate && ( + <div className="col-span-2"> + <Label className="text-xs text-gray-600">실제 완료일</Label> + <div className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3 text-green-500" /> + {formatDate(currentStage.actualDate)} + </div> + </div> + )} </div> + </div> + {/* 편집 가능한 필드들 */} + <div className="space-y-4"> + {/* 계획 날짜 */} <div className="grid gap-2"> - <Label htmlFor="edit-assigneeName"> - <User className="inline w-4 h-4 mr-1" /> - Assignee + <Label htmlFor="edit-planDate" className="flex items-center gap-2"> + <Calendar className="w-4 h-4" /> + 계획 날짜 + {!canEditPlanDate && ( + <Badge variant="outline" className="text-xs"> + 편집 불가 (상태: {getStatusText(currentStage.stageStatus)}) + </Badge> + )} </Label> <Input - id="edit-assigneeName" - value={formData.assigneeName} - onChange={(e) => setFormData({ ...formData, assigneeName: e.target.value })} - placeholder="Enter assignee name" + id="edit-planDate" + type="date" + value={formData.planDate} + onChange={(e) => setFormData({ ...formData, planDate: e.target.value })} + disabled={!canEditPlanDate} + className={!canEditPlanDate ? "bg-gray-100" : ""} /> + {!canEditPlanDate && ( + <p className="text-xs text-gray-500"> + 계획 날짜는 '계획' 상태일 때만 수정할 수 있습니다. + </p> + )} </div> + {/* 노트 */} <div className="grid gap-2"> - <Label htmlFor="edit-notes">Notes</Label> + <Label htmlFor="edit-notes">노트</Label> <Textarea id="edit-notes" value={formData.notes} onChange={(e) => setFormData({ ...formData, notes: e.target.value })} - placeholder="Additional notes" + placeholder="스테이지에 대한 추가 메모나 설명을 입력하세요" rows={3} /> </div> </div> </div> - <DialogFooter className="flex-shrink-0"> + <DialogFooter> <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}> - Cancel + 취소 </Button> <Button onClick={handleSubmit} disabled={isLoading}> {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} - Save Changes + 저장 </Button> </DialogFooter> </DialogContent> </Dialog> ) } - // ============================================================================= // 4. Excel Import Dialog // ============================================================================= @@ -889,6 +938,13 @@ interface ExcelImportDialogProps { projectType: "ship" | "plant" } +interface ImportResult { + documents: any[] + stages: any[] + errors: string[] + warnings: string[] +} + export function ExcelImportDialog({ open, onOpenChange, @@ -896,85 +952,332 @@ export function ExcelImportDialog({ projectType }: ExcelImportDialogProps) { const [file, setFile] = React.useState<File | null>(null) - const [isUploading, setIsUploading] = React.useState(false) + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) + const [importResult, setImportResult] = React.useState<ImportResult | null>(null) + const [processStep, setProcessStep] = React.useState<string>("") + const [progress, setProgress] = React.useState(0) + const router = useRouter() const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const selectedFile = e.target.files?.[0] if (selectedFile) { + // 파일 유효성 검사 + if (!validateFileExtension(selectedFile)) { + toast.error("Excel 파일(.xlsx, .xls)만 업로드 가능합니다.") + return + } + + if (!validateFileSize(selectedFile, 10)) { + toast.error("파일 크기는 10MB 이하여야 합니다.") + return + } + setFile(selectedFile) + setImportResult(null) } } + const validateFileExtension = (file: File): boolean => { + const allowedExtensions = ['.xlsx', '.xls'] + const fileName = file.name.toLowerCase() + return allowedExtensions.some(ext => fileName.endsWith(ext)) + } + + const validateFileSize = (file: File, maxSizeMB: number): boolean => { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes + } + + // 템플릿 다운로드 + const handleDownloadTemplate = async () => { + setIsDownloadingTemplate(true) + try { + const workbook = await createImportTemplate(projectType, contractId) + const buffer = await workbook.xlsx.writeBuffer() + + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `문서_임포트_템플릿_${projectType}_${new Date().toISOString().split('T')[0]}.xlsx` + link.click() + + window.URL.revokeObjectURL(url) + toast.success("템플릿 파일이 다운로드되었습니다.") + } catch (error) { + toast.error("템플릿 다운로드 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : "알 수 없는 오류")) + } finally { + setIsDownloadingTemplate(false) + } + } + + // 엑셀 파일 처리 const handleImport = async () => { if (!file) { - toast.error("Please select a file.") + toast.error("파일을 선택해주세요.") return } - setIsUploading(true) + setIsProcessing(true) + setProgress(0) + try { - // TODO: API call to upload and process Excel file - toast.success("Excel file imported successfully.") - onOpenChange(false) - setFile(null) + setProcessStep("파일 읽는 중...") + setProgress(20) + + const workbook = new ExcelJS.Workbook() + const buffer = await file.arrayBuffer() + await workbook.xlsx.load(buffer) + + setProcessStep("데이터 검증 중...") + setProgress(40) + + // 워크시트 확인 + const documentsSheet = workbook.getWorksheet('Documents') || workbook.getWorksheet(1) + const stagesSheet = workbook.getWorksheet('Stage Plan Dates') || workbook.getWorksheet(2) + + if (!documentsSheet) { + throw new Error("Documents 시트를 찾을 수 없습니다.") + } + + setProcessStep("문서 데이터 파싱 중...") + setProgress(60) + + // 문서 데이터 파싱 + const documentData = await parseDocumentsSheet(documentsSheet, projectType) + + setProcessStep("스테이지 데이터 파싱 중...") + setProgress(80) + + // 스테이지 데이터 파싱 (선택사항) + let stageData: any[] = [] + if (stagesSheet) { + stageData = await parseStagesSheet(stagesSheet) + } + + setProcessStep("서버에 업로드 중...") + setProgress(90) + + // 서버로 데이터 전송 + const result = await uploadImportData({ + contractId, + documents: documentData.validData, + stages: stageData, + projectType + }) + + if (result.success) { + setImportResult({ + documents: documentData.validData, + stages: stageData, + errors: documentData.errors, + warnings: result.warnings || [] + }) + setProgress(100) + toast.success(`${documentData.validData.length}개 문서가 성공적으로 임포트되었습니다.`) + } else { + throw new Error(result.error || "임포트에 실패했습니다.") + } + } catch (error) { - toast.error("Error importing Excel file.") + toast.error(error instanceof Error ? error.message : "임포트 중 오류가 발생했습니다.") + setImportResult({ + documents: [], + stages: [], + errors: [error instanceof Error ? error.message : "알 수 없는 오류"], + warnings: [] + }) } finally { - setIsUploading(false) + setIsProcessing(false) + setProcessStep("") + setProgress(0) } } + const handleClose = () => { + setFile(null) + setImportResult(null) + setProgress(0) + setProcessStep("") + onOpenChange(false) + } + + const handleConfirmImport = () => { + // 페이지 새로고침하여 데이터 갱신 + router.refresh() + handleClose() + } + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> + <DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> <DialogTitle> <FileSpreadsheet className="inline w-5 h-5 mr-2" /> - Import Excel File + Excel 파일 임포트 </DialogTitle> <DialogDescription> - Upload an Excel file containing document list for batch registration. + Excel 파일을 사용하여 문서를 일괄 등록합니다. </DialogDescription> </DialogHeader> - <div className="grid gap-4 py-4"> - <div className="grid gap-2"> - <Label htmlFor="excel-file">Select Excel File</Label> - <Input - id="excel-file" - type="file" - accept=".xlsx,.xls" - onChange={handleFileChange} - className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" - /> - {file && ( - <p className="text-sm text-gray-600 mt-1"> - Selected file: {file.name} + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* 템플릿 다운로드 섹션 */} + <div className="border rounded-lg p-4 bg-blue-50/30"> + <h4 className="font-medium text-blue-800 mb-2">1. 템플릿 다운로드</h4> + <p className="text-sm text-blue-700 mb-3"> + 올바른 형식과 드롭다운이 적용된 템플릿을 다운로드하세요. </p> + <Button + variant="outline" + size="sm" + onClick={handleDownloadTemplate} + disabled={isDownloadingTemplate} + > + {isDownloadingTemplate ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : ( + <Download className="h-4 w-4 mr-2" /> + )} + 템플릿 다운로드 + </Button> + </div> + + {/* 파일 업로드 섹션 */} + <div className="border rounded-lg p-4"> + <h4 className="font-medium mb-2">2. 파일 업로드</h4> + <div className="grid gap-2"> + <Label htmlFor="excel-file">Excel 파일 선택</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isProcessing} + className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" + /> + {file && ( + <p className="text-sm text-gray-600 mt-1"> + 선택된 파일: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + </div> + + {/* 진행 상태 */} + {isProcessing && ( + <div className="border rounded-lg p-4 bg-yellow-50/30"> + <div className="flex items-center gap-2 mb-2"> + <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> + <span className="text-sm font-medium text-yellow-800">처리 중...</span> + </div> + <p className="text-sm text-yellow-700 mb-2">{processStep}</p> + <Progress value={progress} className="h-2" /> + </div> )} - </div> - <div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> - <h4 className="font-medium text-blue-800 mb-2">File Format Guide</h4> - <div className="text-sm text-blue-700 space-y-1"> - <p>• First row must be header row</p> - <p>• Required columns: Document Number, Document Title, Document Class</p> - {projectType === "plant" && ( - <p>• Optional columns: Vendor Document Number</p> - )} - <p>• Supported formats: .xlsx, .xls</p> + {/* 임포트 결과 */} + {importResult && ( + <div className="space-y-3"> + {importResult.documents.length > 0 && ( + <Alert> + <CheckCircle className="h-4 w-4" /> + <AlertDescription> + <strong>{importResult.documents.length}개</strong> 문서가 성공적으로 임포트되었습니다. + {importResult.stages.length > 0 && ( + <> ({importResult.stages.length}개 스테이지 계획날짜 포함)</> + )} + </AlertDescription> + </Alert> + )} + + {importResult.warnings.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>경고:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.warnings.map((warning, index) => ( + <li key={index} className="text-sm">{warning}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {importResult.errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>오류:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.errors.map((error, index) => ( + <li key={index} className="text-sm">{error}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + </div> + )} + + {/* 파일 형식 가이드 */} + <div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> + <h4 className="font-medium text-gray-800 mb-2">파일 형식 가이드</h4> + <div className="text-sm text-gray-700 space-y-1"> + <p><strong>Documents 시트:</strong></p> + <ul className="ml-4 list-disc"> + <li>Document Number* (문서번호)</li> + <li>Document Name* (문서명)</li> + <li>Document Class* (문서클래스 - 드롭다운 선택)</li> + {projectType === "plant" && ( + <li>Vendor Doc No. (벤더문서번호)</li> + )} + </ul> + <p className="mt-2"><strong>Stage Plan Dates 시트 (선택사항):</strong></p> + <ul className="ml-4 list-disc"> + <li>Document Number* (문서번호)</li> + <li>Stage Name* (스테이지명 - 드롭다운 선택, 해당 문서클래스에 맞는 스테이지만 선택)</li> + <li>Plan Date (계획날짜: YYYY-MM-DD)</li> + </ul> + <p className="mt-2 text-green-600"><strong>스마트 기능:</strong></p> + <ul className="ml-4 list-disc text-green-600"> + <li>Document Class는 드롭다운으로 정확한 값만 선택 가능</li> + <li>Stage Name도 드롭다운으로 오타 방지</li> + <li>"사용 가이드" 시트에서 각 클래스별 사용 가능한 스테이지 확인 가능</li> + </ul> + <p className="mt-2 text-red-600">* 필수 항목</p> + </div> </div> </div> </div> - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - Cancel - </Button> - <Button onClick={handleImport} disabled={!file || isUploading}> - {isUploading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} - {isUploading ? "Importing..." : "Import"} + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={handleClose}> + {importResult ? "닫기" : "취소"} </Button> + {!importResult ? ( + <Button + onClick={handleImport} + disabled={!file || isProcessing} + > + {isProcessing ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : ( + <Upload className="h-4 w-4 mr-2" /> + )} + {isProcessing ? "처리 중..." : "임포트 시작"} + </Button> + ) : importResult.documents.length > 0 ? ( + <Button onClick={handleConfirmImport}> + 완료 및 새로고침 + </Button> + ) : null} </DialogFooter> </DialogContent> </Dialog> @@ -1105,3 +1408,224 @@ export function DeleteDocumentsDialog({ </Drawer> ) } + +// ============================================================================= +// Helper Functions for Excel Import +// ============================================================================= + +// ExcelJS 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...) +function getExcelColumnName(index: number): string { + let result = "" + while (index > 0) { + index-- + result = String.fromCharCode(65 + (index % 26)) + result + index = Math.floor(index / 26) + } + return result +} + +// 헤더 행 스타일링 함수 +function styleHeaderRow(headerRow: ExcelJS.Row, bgColor: string = 'FF4472C4') { + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: bgColor } + } + cell.font = { + color: { argb: 'FFFFFFFF' }, + bold: true + } + cell.alignment = { + horizontal: 'center', + vertical: 'middle' + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + if (String(cell.value).includes('*')) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE74C3C' } + } + } + }) +} + +// 템플릿 생성 함수 +async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { + const res = await getDocumentClassOptionsByContract(contractId); + if (!res.success) throw new Error(res.error || "데이터 로딩 실패"); + + const documentClasses = res.data.classes; // [{id, code, description}] + const options = res.data.options; // [{documentClassId, optionValue, ...}] + + // 클래스별 옵션 맵 + const optionsByClassId = new Map<number, string[]>(); + for (const c of documentClasses) optionsByClassId.set(c.id, []); + for (const o of options) { + optionsByClassId.get(o.documentClassId)?.push(o.optionValue); + } + + // 모든 스테이지 명 (유니크) + const allStageNames = Array.from(new Set(options.map(o => o.optionValue))); + + const workbook = new ExcelJS.Workbook(); + + // ================= ReferenceData (hidden) ================= + const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }); + + // A열: DocumentClasses + referenceSheet.getCell("A1").value = "DocumentClasses"; + documentClasses.forEach((docClass, i) => { + referenceSheet.getCell(`A${i + 2}`).value = `${docClass.description}`; + }); + + // B열부터: 각 클래스의 Stage 옵션 + let currentCol = 2; // B + for (const docClass of documentClasses) { + const colLetter = getExcelColumnName(currentCol); + referenceSheet.getCell(`${colLetter}1`).value = docClass.description; + + const list = optionsByClassId.get(docClass.id) ?? []; + list.forEach((v, i) => { + referenceSheet.getCell(`${colLetter}${i + 2}`).value = v; + }); + + currentCol++; + } + + // 마지막 열: AllStageNames + const allStagesCol = getExcelColumnName(currentCol); + referenceSheet.getCell(`${allStagesCol}1`).value = "AllStageNames"; + allStageNames.forEach((v, i) => { + referenceSheet.getCell(`${allStagesCol}${i + 2}`).value = v; + }); + + // ================= Documents ================= + const documentsSheet = workbook.addWorksheet("Documents"); + const documentHeaders = [ + "Document Number*", + "Document Name*", + "Document Class*", + ...(projectType === "plant" ? ["Vendor Doc No."] : []), + "Notes", + ]; + const documentHeaderRow = documentsSheet.addRow(documentHeaders); + styleHeaderRow(documentHeaderRow); + + const sampleDocumentData = + projectType === "ship" + ? [ + "SH-2024-001", + "기본 설계 도면", + documentClasses[0] + ? `${documentClasses[0].description}` + : "", + "참고사항", + ] + : [ + "PL-2024-001", + "공정 설계 도면", + documentClasses[0] + ? `${documentClasses[0].description}` + : "", + "V-001", + "참고사항", + ]; + + documentsSheet.addRow(sampleDocumentData); + + // Document Class 드롭다운 + const docClassColIndex = 3; // C + const docClassCol = getExcelColumnName(docClassColIndex); + documentsSheet.dataValidations.add(`${docClassCol}2:${docClassCol}1000`, { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$A$2:$A$${documentClasses.length + 1}`], + }); + + documentsSheet.columns = [ + { width: 15 }, + { width: 25 }, + { width: 28 }, + ...(projectType === "plant" ? [{ width: 18 }] : []), + { width: 24 }, + ]; + + // ================= Stage Plan Dates ================= + const stagesSheet = workbook.addWorksheet("Stage Plan Dates"); + const stageHeaderRow = stagesSheet.addRow(["Document Number*", "Stage Name*", "Plan Date"]); + styleHeaderRow(stageHeaderRow, "FF27AE60"); + + const firstClass = documentClasses[0]; + const firstClassOpts = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : []; + + const sampleStageData = [ + [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[0] ?? "", "2024-02-15"], + [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[1] ?? "", "2024-03-01"], + ]; + + sampleStageData.forEach(row => { + const r = stagesSheet.addRow(row); + r.getCell(3).numFmt = "yyyy-mm-dd"; + }); + + // Stage Name 드롭다운 (전체) + stagesSheet.dataValidations.add("B2:B1000", { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$${allStagesCol}$2:$${allStagesCol}$${allStageNames.length + 1}`], + promptTitle: "Stage Name 선택", + prompt: "Document의 Document Class에 해당하는 Stage Name을 선택하세요.", + }); + + stagesSheet.columns = [{ width: 15 }, { width: 30 }, { width: 12 }]; + + // ================= 사용 가이드 ================= + const guideSheet = workbook.addWorksheet("사용 가이드"); + const guideContent: (string[])[] = [ + ["문서 임포트 가이드"], + [""], + ["1. Documents 시트"], + [" - Document Number*: 고유한 문서 번호를 입력하세요"], + [" - Document Name*: 문서명을 입력하세요"], + [" - Document Class*: 드롭다운에서 문서 클래스를 선택하세요"], + [" - Vendor Doc No.: 벤더 문서 번호"], + [" - Notes: 참고사항"], + [""], + ["2. Stage Plan Dates 시트 (선택사항)"], + [" - Document Number*: Documents 시트의 Document Number와 일치해야 합니다"], + [" - Stage Name*: 드롭다운에서 해당 문서 클래스에 맞는 스테이지명을 선택하세요"], + [" - Plan Date: 계획 날짜 (YYYY-MM-DD 형식)"], + [""], + ["3. 주의사항"], + [" - * 표시는 필수 항목입니다"], + [" - Document Number는 고유해야 합니다"], + [" - Stage Name은 해당 Document의 Document Class에 속한 것만 유효합니다"], + [" - 날짜는 YYYY-MM-DD 형식으로 입력하세요"], + [""], + ["4. Document Class별 사용 가능한 Stage Names"], + [""], + ]; + + for (const c of documentClasses) { + guideContent.push([`${c.code} - ${c.description}:`]); + (optionsByClassId.get(c.id) ?? []).forEach(v => guideContent.push([` • ${v}`])); + guideContent.push([""]); + } + + guideContent.forEach((row, i) => { + const r = guideSheet.addRow(row); + if (i === 0) r.getCell(1).font = { bold: true, size: 14 }; + else if (row[0] && row[0].includes(":") && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true }; + }); + guideSheet.getColumn(1).width = 60; + + return workbook; +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx index 070d6904..eda6f57a 100644 --- a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx +++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx @@ -1,3 +1,4 @@ + "use client" import React from "react" @@ -6,14 +7,37 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Progress } from "@/components/ui/progress" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { Calendar, CheckCircle, Edit, - FileText + FileText, + Loader2, + Target, + User } from "lucide-react" import { formatDate } from "@/lib/utils" import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { updateStage } from "./document-stages-service" interface DocumentStagesExpandedContentProps { document: DocumentStagesOnlyView @@ -74,35 +98,51 @@ export function DocumentStagesExpandedContent({ : "bg-white border-gray-200" )} > - {/* 스테이지 순서 */} - <div className="absolute -top-1 -left-1 bg-gray-600 text-white rounded-full w-4 h-4 flex items-center justify-center text-xs font-medium"> - {index + 1} - </div> + {/* 편집 버튼 - 우측 상단에 절대 위치 */} + <Button + size="sm" + variant="ghost" + onClick={() => onEditStage(stage.id)} + className="absolute top-1 right-1 h-5 w-5 p-0 hover:bg-gray-100" + > + <Edit className="h-3 w-3" /> + </Button> - {/* 스테이지명 */} - <div className="mb-2 pr-6"> - <div className="font-medium text-sm truncate" title={stage.stageName}> + {/* 상단: 스테이지 순서, 스테이지명, 상태를 한 줄에 배치 */} + <div className="flex items-center gap-2 mb-2 pr-6"> + {/* 스테이지 순서 */} + <div + className="text-white rounded-full min-w-[20px] w-4 h-4 flex items-center justify-center text-xs font-bold flex-shrink-0" + style={{ + backgroundColor: '#4B5563', + borderRadius: '50%' + }} + > + {index + 1} + </div> + {/* 스테이지명 */} + <div className="font-medium text-sm truncate flex-1" title={stage.stageName}> {stage.stageName} </div> - {isCurrentStage && ( - <Badge variant="default" className="text-xs px-1 py-0 mt-1"> - 현재 + + {/* 상태 배지 */} + <div className="flex items-center gap-1 flex-shrink-0"> + <Badge + variant={getStatusVariant(stage.stageStatus)} + className="text-xs px-1.5 py-0" + > + {getStatusText(stage.stageStatus)} </Badge> - )} - </div> - - {/* 상태 */} - <div className="mb-2"> - <Badge - variant={getStatusVariant(stage.stageStatus)} - className="text-xs px-1.5 py-0" - > - {getStatusText(stage.stageStatus)} - </Badge> + {isCurrentStage && ( + <Badge variant="default" className="text-xs px-1 py-0"> + 현재 + </Badge> + )} + </div> </div> {/* 날짜 정보 */} - <div className="space-y-1 text-xs text-gray-600 mb-2"> + <div className="space-y-1 text-xs text-gray-600"> {planDate && ( <div className="flex items-center gap-1"> <Calendar className="h-3 w-3" /> @@ -116,16 +156,6 @@ export function DocumentStagesExpandedContent({ </div> )} </div> - - {/* 편집 버튼 */} - <Button - size="sm" - variant="ghost" - onClick={() => onEditStage(stage.id)} - className="absolute top-1 right-1 h-5 w-5 p-0 hover:bg-gray-100" - > - <Edit className="h-3 w-3" /> - </Button> </div> ) })} @@ -133,4 +163,4 @@ export function DocumentStagesExpandedContent({ )} </div> ) -}
\ No newline at end of file +} diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 108b5869..1e60a062 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -5,7 +5,7 @@ import { revalidatePath, revalidateTag } from "next/cache" import { redirect } from "next/navigation" import db from "@/db/db" import { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages } from "@/db/schema" -import { and, eq, asc, desc, sql, inArray, max, ne ,or, ilike} from "drizzle-orm" +import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm" import { createDocumentSchema, updateDocumentSchema, @@ -72,7 +72,7 @@ export async function updateDocument(data: UpdateDocumentData) { // 3. 캐시 무효화 revalidatePath(`/contracts/${updatedDocument.contractId}/documents`) - + return { success: true, data: updatedDocument @@ -85,28 +85,28 @@ export async function updateDocument(data: UpdateDocumentData) { // 문서 삭제 (소프트 삭제) export async function deleteDocument(input: { id: number }) { noStore() - + try { const validatedData = deleteDocumentSchema.parse(input) - + // 문서 존재 확인 const existingDoc = await db.query.documents.findFirst({ where: eq(documents.id, validatedData.id) }) - + if (!existingDoc) { throw new Error("문서를 찾을 수 없습니다") } - + // 연관된 스테이지 확인 const relatedStages = await db.query.issueStages.findMany({ where: eq(issueStages.documentId, validatedData.id) }) - + if (relatedStages.length > 0) { throw new Error("연관된 스테이지가 있는 문서는 삭제할 수 없습니다. 먼저 스테이지를 삭제해주세요.") } - + // 소프트 삭제 (상태 변경) await db .update(documents) @@ -115,16 +115,16 @@ export async function deleteDocument(input: { id: number }) { updatedAt: new Date(), }) .where(eq(documents.id, validatedData.id)) - + // 캐시 무효화 revalidateTag(`documents-${existingDoc.contractId}`) revalidatePath(`/contracts/${existingDoc.contractId}/documents`) - + return { success: true, message: "문서가 성공적으로 삭제되었습니다" } - + } catch (error) { console.error("Error deleting document:", error) return { @@ -217,19 +217,19 @@ export async function deleteDocuments(data: DeleteDocumentsData) { // 스테이지 생성 export async function createStage(input: CreateStageInput) { noStore() - + try { const validatedData = createStageSchema.parse(input) - + // 문서 존재 확인 const document = await db.query.documents.findFirst({ where: eq(documents.id, validatedData.documentId) }) - + if (!document) { throw new Error("문서를 찾을 수 없습니다") } - + // 스테이지명 중복 검사 const existingStage = await db.query.issueStages.findFirst({ where: and( @@ -237,11 +237,11 @@ export async function createStage(input: CreateStageInput) { eq(issueStages.stageName, validatedData.stageName) ) }) - + if (existingStage) { throw new Error("이미 존재하는 스테이지명입니다") } - + // 스테이지 순서 자동 설정 (제공되지 않은 경우) let stageOrder = validatedData.stageOrder if (stageOrder === 0 || stageOrder === undefined) { @@ -249,10 +249,10 @@ export async function createStage(input: CreateStageInput) { .select({ maxOrder: max(issueStages.stageOrder) }) .from(issueStages) .where(eq(issueStages.documentId, validatedData.documentId)) - + stageOrder = (maxOrderResult[0]?.maxOrder ?? -1) + 1 } - + // 스테이지 생성 const [newStage] = await db.insert(issueStages).values({ documentId: validatedData.documentId, @@ -269,18 +269,18 @@ export async function createStage(input: CreateStageInput) { createdAt: new Date(), updatedAt: new Date(), }).returning() - + // 캐시 무효화 revalidateTag(`documents-${document.contractId}`) revalidateTag(`document-${validatedData.documentId}`) revalidatePath(`/contracts/${document.contractId}/documents`) - + return { success: true, data: newStage, message: "스테이지가 성공적으로 생성되었습니다" } - + } catch (error) { console.error("Error creating stage:", error) return { @@ -293,10 +293,10 @@ export async function createStage(input: CreateStageInput) { // 스테이지 수정 export async function updateStage(input: UpdateStageInput) { noStore() - + try { const validatedData = updateStageSchema.parse(input) - + // 스테이지 존재 확인 const existingStage = await db.query.issueStages.findFirst({ where: eq(issueStages.id, validatedData.id), @@ -304,11 +304,11 @@ export async function updateStage(input: UpdateStageInput) { document: true } }) - + if (!existingStage) { throw new Error("스테이지를 찾을 수 없습니다") } - + // 스테이지명 중복 검사 (스테이지명 변경 시) if (validatedData.stageName && validatedData.stageName !== existingStage.stageName) { const duplicateStage = await db.query.issueStages.findFirst({ @@ -317,12 +317,12 @@ export async function updateStage(input: UpdateStageInput) { eq(issueStages.stageName, validatedData.stageName) ) }) - + if (duplicateStage) { throw new Error("이미 존재하는 스테이지명입니다") } } - + // 스테이지 업데이트 const [updatedStage] = await db .update(issueStages) @@ -332,18 +332,18 @@ export async function updateStage(input: UpdateStageInput) { }) .where(eq(issueStages.id, validatedData.id)) .returning() - + // 캐시 무효화 revalidateTag(`documents-${existingStage.document.contractId}`) revalidateTag(`document-${existingStage.documentId}`) revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) - + return { success: true, data: updatedStage, message: "스테이지가 성공적으로 수정되었습니다" } - + } catch (error) { console.error("Error updating stage:", error) return { @@ -356,10 +356,10 @@ export async function updateStage(input: UpdateStageInput) { // 스테이지 삭제 export async function deleteStage(input: { id: number }) { noStore() - + try { const validatedData = deleteStageSchema.parse(input) - + // 스테이지 존재 확인 const existingStage = await db.query.issueStages.findFirst({ where: eq(issueStages.id, validatedData.id), @@ -367,29 +367,29 @@ export async function deleteStage(input: { id: number }) { document: true } }) - + if (!existingStage) { throw new Error("스테이지를 찾을 수 없습니다") } - + // 연관된 리비전 확인 (향후 구현 시) // const relatedRevisions = await db.query.revisions.findMany({ // where: eq(revisions.issueStageId, validatedData.id) // }) - + // if (relatedRevisions.length > 0) { // throw new Error("연관된 리비전이 있는 스테이지는 삭제할 수 없습니다") // } - + // 스테이지 삭제 await db.delete(issueStages).where(eq(issueStages.id, validatedData.id)) - + // 스테이지 순서 재정렬 const remainingStages = await db.query.issueStages.findMany({ where: eq(issueStages.documentId, existingStage.documentId), orderBy: [issueStages.stageOrder] }) - + for (let i = 0; i < remainingStages.length; i++) { if (remainingStages[i].stageOrder !== i) { await db @@ -398,17 +398,17 @@ export async function deleteStage(input: { id: number }) { .where(eq(issueStages.id, remainingStages[i].id)) } } - + // 캐시 무효화 revalidateTag(`documents-${existingStage.document.contractId}`) revalidateTag(`document-${existingStage.documentId}`) revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) - + return { success: true, message: "스테이지가 성공적으로 삭제되었습니다" } - + } catch (error) { console.error("Error deleting stage:", error) return { @@ -421,22 +421,22 @@ export async function deleteStage(input: { id: number }) { // 스테이지 순서 변경 export async function reorderStages(input: any) { noStore() - + try { const validatedData = reorderStagesSchema.parse(input) - + // 스테이지 순서 유효성 검사 validateStageOrder(validatedData.stages) - + // 문서 존재 확인 const document = await db.query.documents.findFirst({ where: eq(documents.id, validatedData.documentId) }) - + if (!document) { throw new Error("문서를 찾을 수 없습니다") } - + // 스테이지들이 해당 문서에 속하는지 확인 const stageIds = validatedData.stages.map(s => s.id) const existingStages = await db.query.issueStages.findMany({ @@ -445,34 +445,34 @@ export async function reorderStages(input: any) { inArray(issueStages.id, stageIds) ) }) - + if (existingStages.length !== validatedData.stages.length) { throw new Error("일부 스테이지가 해당 문서에 속하지 않습니다") } - + // 트랜잭션으로 순서 업데이트 await db.transaction(async (tx) => { for (const stage of validatedData.stages) { await tx .update(issueStages) - .set({ + .set({ stageOrder: stage.stageOrder, updatedAt: new Date() }) .where(eq(issueStages.id, stage.id)) } }) - + // 캐시 무효화 revalidateTag(`documents-${document.contractId}`) revalidateTag(`document-${validatedData.documentId}`) revalidatePath(`/contracts/${document.contractId}/documents`) - + return { success: true, message: "스테이지 순서가 성공적으로 변경되었습니다" } - + } catch (error) { console.error("Error reordering stages:", error) return { @@ -489,10 +489,10 @@ export async function reorderStages(input: any) { // 일괄 문서 생성 (엑셀 임포트) export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult> { noStore() - + try { const validatedData = bulkCreateDocumentsSchema.parse(input) - + const result: ExcelImportResult = { totalRows: validatedData.documents.length, successCount: 0, @@ -500,12 +500,12 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult errors: [], createdDocuments: [] } - + // 트랜잭션으로 일괄 처리 await db.transaction(async (tx) => { for (let i = 0; i < validatedData.documents.length; i++) { const docData = validatedData.documents[i] - + try { // 문서번호 중복 검사 const existingDoc = await tx.query.documents.findFirst({ @@ -515,7 +515,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult eq(documents.status, "ACTIVE") ) }) - + if (existingDoc) { result.errors.push({ row: i + 2, // 엑셀 행 번호 (헤더 포함) @@ -525,7 +525,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult result.failureCount++ continue } - + // 문서 생성 const [newDoc] = await tx.insert(documents).values({ contractId: validatedData.contractId, @@ -545,14 +545,14 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult createdAt: new Date(), updatedAt: new Date(), }).returning() - + result.createdDocuments.push({ id: newDoc.id, docNumber: newDoc.docNumber, title: newDoc.title }) result.successCount++ - + } catch (error) { result.errors.push({ row: i + 2, @@ -562,13 +562,13 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult } } }) - + // 캐시 무효화 revalidateTag(`documents-${validatedData.contractId}`) revalidatePath(`/contracts/${validatedData.contractId}/documents`) - + return result - + } catch (error) { console.error("Error bulk creating documents:", error) throw new Error("일괄 문서 생성 중 오류가 발생했습니다") @@ -578,20 +578,20 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult // 일괄 상태 업데이트 export async function bulkUpdateStageStatus(input: any) { noStore() - + try { const validatedData = bulkUpdateStatusSchema.parse(input) - + // 스테이지들 존재 확인 const existingStages = await db.query.issueStages.findMany({ where: inArray(issueStages.id, validatedData.stageIds), with: { document: true } }) - + if (existingStages.length !== validatedData.stageIds.length) { throw new Error("일부 스테이지를 찾을 수 없습니다") } - + // 일괄 업데이트 await db .update(issueStages) @@ -601,19 +601,19 @@ export async function bulkUpdateStageStatus(input: any) { updatedAt: new Date() }) .where(inArray(issueStages.id, validatedData.stageIds)) - + // 관련된 계약들의 캐시 무효화 const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] for (const contractId of contractIds) { revalidateTag(`documents-${contractId}`) revalidatePath(`/contracts/${contractId}/documents`) } - + return { success: true, message: `${validatedData.stageIds.length}개 스테이지의 상태가 업데이트되었습니다` } - + } catch (error) { console.error("Error bulk updating stage status:", error) return { @@ -626,20 +626,20 @@ export async function bulkUpdateStageStatus(input: any) { // 일괄 담당자 지정 export async function bulkAssignStages(input: any) { noStore() - + try { const validatedData = bulkAssignSchema.parse(input) - + // 스테이지들 존재 확인 const existingStages = await db.query.issueStages.findMany({ where: inArray(issueStages.id, validatedData.stageIds), with: { document: true } }) - + if (existingStages.length !== validatedData.stageIds.length) { throw new Error("일부 스테이지를 찾을 수 없습니다") } - + // 일괄 담당자 지정 await db .update(issueStages) @@ -649,19 +649,19 @@ export async function bulkAssignStages(input: any) { updatedAt: new Date() }) .where(inArray(issueStages.id, validatedData.stageIds)) - + // 관련된 계약들의 캐시 무효화 const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] for (const contractId of contractIds) { revalidateTag(`documents-${contractId}`) revalidatePath(`/contracts/${contractId}/documents`) } - + return { success: true, message: `${validatedData.stageIds.length}개 스테이지에 담당자가 지정되었습니다` } - + } catch (error) { console.error("Error bulk assigning stages:", error) return { @@ -673,14 +673,25 @@ export async function bulkAssignStages(input: any) { // 문서번호 타입 목록 조회 -export async function getDocumentNumberTypes() { +export async function getDocumentNumberTypes(contractId: number) { try { + + const project = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!project) { + return { + success: false, + } + } + const types = await db .select() .from(documentNumberTypes) - .where(eq(documentNumberTypes.isActive, true)) + .where(and (eq(documentNumberTypes.isActive, true), eq(documentNumberTypes.projectId,project.projectId))) .orderBy(asc(documentNumberTypes.name)) - + return { success: true, data: types } } catch (error) { console.error("문서번호 타입 조회 실패:", error) @@ -691,6 +702,7 @@ export async function getDocumentNumberTypes() { // 문서번호 타입 설정 조회 export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) { try { + const configs = await db .select({ id: documentNumberTypeConfigs.id, @@ -698,32 +710,31 @@ export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) description: documentNumberTypeConfigs.description, remark: documentNumberTypeConfigs.remark, codeGroupId: documentNumberTypeConfigs.codeGroupId, - documentClassId: documentNumberTypeConfigs.documentClassId, + // documentClassId: documentNumberTypeConfigs.documentClassId, codeGroup: { id: codeGroups.id, groupId: codeGroups.groupId, description: codeGroups.description, controlType: codeGroups.controlType, }, - documentClass: { - id: documentClasses.id, - code: documentClasses.code, - description: documentClasses.description, - } + // documentClass: { + // id: documentClasses.id, + // code: documentClasses.code, + // description: documentClasses.description, + // } }) .from(documentNumberTypeConfigs) .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id)) - .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id)) + // .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id)) .where( and( eq(documentNumberTypeConfigs.documentNumberTypeId, documentNumberTypeId), - eq(documentNumberTypeConfigs.isActive, true) + eq(documentNumberTypeConfigs.isActive, true), + // eq(documentNumberTypeConfigs.projectId, project.projectId) ) ) .orderBy(asc(documentNumberTypeConfigs.sdq)) - console.log(configs,"configs") - return { success: true, data: configs } } catch (error) { console.error("문서번호 타입 설정 조회 실패:", error) @@ -733,8 +744,9 @@ export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) // 콤보박스 옵션 조회 export async function getComboBoxOptions(codeGroupId: number) { - console.log(codeGroupId,"codeGroupId") try { + + const settings = await db .select({ id: comboBoxSettings.id, @@ -746,8 +758,6 @@ export async function getComboBoxOptions(codeGroupId: number) { .where(eq(comboBoxSettings.codeGroupId, codeGroupId)) .orderBy(asc(comboBoxSettings.code)) - console.log("settings",settings) - return { success: true, data: settings } } catch (error) { console.error("콤보박스 옵션 조회 실패:", error) @@ -756,14 +766,29 @@ export async function getComboBoxOptions(codeGroupId: number) { } // 문서 클래스 목록 조회 -export async function getDocumentClasses() { +export async function getDocumentClasses(contractId:number) { try { + const projectId = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!projectId) { + return { + success: false, + } + } + const classes = await db .select() .from(documentClasses) - .where(eq(documentClasses.isActive, true)) + .where( + and( + eq(documentClasses.isActive, true), + eq(documentClasses.projectId, projectId.projectId) + ) + ) .orderBy(asc(documentClasses.description)) - + return { success: true, data: classes } } catch (error) { console.error("문서 클래스 조회 실패:", error) @@ -783,8 +808,8 @@ export async function getDocumentClassOptions(documentClassId: number) { eq(documentClassOptions.isActive, true) ) ) - .orderBy(asc(documentClassOptions.sortOrder)) - + // .orderBy(asc(documentClassOptions.sortOrder)) + return { success: true, data: options } } catch (error) { console.error("문서 클래스 옵션 조회 실패:", error) @@ -792,10 +817,68 @@ export async function getDocumentClassOptions(documentClassId: number) { } } +export async function getDocumentClassOptionsByContract(contractId: number) { + try { + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + columns: { projectId: true }, + }); + + if (!contract) { + return { success: false, error: "계약을 찾을 수 없습니다." as const }; + } + // 프로젝트의 활성 클래스들 + const classes = await db + .select({ + id: documentClasses.id, + code: documentClasses.code, + description: documentClasses.value, + }) + .from(documentClasses) + .where( + and( + eq(documentClasses.isActive, true), + eq(documentClasses.projectId, contract.projectId) + ) + ) + .orderBy(asc(documentClasses.value)); + + if (classes.length === 0) { + return { success: true, data: { classes: [], options: [] as any[] } }; + } + + // 해당 클래스들의 모든 옵션 한 방에 + const classIds = classes.map(c => c.id); + + + const options = await db + .select({ + id: documentClassOptions.id, + documentClassId: documentClassOptions.documentClassId, + optionValue: documentClassOptions.description, + // sortOrder: documentClassOptions.sortOrder, + }) + .from(documentClassOptions) + .where( + and( + inArray(documentClassOptions.documentClassId, classIds), + eq(documentClassOptions.isActive, true) + ) + ); + // 필요하면 .orderBy(asc(documentClassOptions.sortOrder ?? documentClassOptions.optionValue)) + + return { success: true, data: { classes, options } }; + } catch (error) { + console.log(error) + console.error("문서 클래스/옵션 일괄 조회 실패:", error); + return { success: false, error: "문서 클래스/옵션을 불러올 수 없습니다." as const }; + } +} + // 문서번호 생성 export async function generateDocumentNumber(configs: any[], values: Record<string, string>) { let docNumber = "" - + configs.forEach((config) => { const value = values[`field_${config.sdq}`] || "" if (value) { @@ -806,7 +889,7 @@ export async function generateDocumentNumber(configs: any[], values: Record<stri } } }) - + return docNumber.replace(/-$/, "") // 마지막 하이픈 제거 } @@ -843,6 +926,9 @@ export async function createDocument(data: CreateDocumentData) { const configsResult = await getDocumentNumberTypeConfigs( data.documentNumberTypeId ) + + console.log(configsResult, "configsResult") + if (!configsResult.success) { return { success: false, error: configsResult.error } } @@ -860,10 +946,10 @@ export async function createDocument(data: CreateDocumentData) { updatedAt: new Date(), // 선택 - pic: data.pic ?? null, - vendorDocNumber: data.vendorDocNumber ?? null, + pic: data.pic ?? null, + vendorDocNumber: data.vendorDocNumber ?? null, - } + } const [document] = await db .insert(documents) @@ -892,13 +978,13 @@ export async function createDocument(data: CreateDocumentData) { if (stageOptionsResult.success && stageOptionsResult.data.length > 0) { const now = new Date() const stageInserts = stageOptionsResult.data.map((opt, idx) => ({ - documentId: document.id, - stageName: opt.optionValue, - stageOrder: opt.sortOrder ?? idx + 1, + documentId: document.id, + stageName: opt.optionValue, + stageOrder: opt.sortOrder ?? idx + 1, stageStatus: "PLANNED" as const, - planDate: data.planDates[opt.id] ?? null, - createdAt: now, - updatedAt: now, + planDate: data.planDates[opt.id] ?? null, + createdAt: now, + updatedAt: now, })) await db.insert(issueStages).values(stageInserts) } @@ -955,10 +1041,10 @@ export async function getDocumentStagesOnly( // 정렬 처리 const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => - item.desc - ? desc(documentStagesOnlyView[item.id]) - : asc(documentStagesOnlyView[item.id]) - ) + item.desc + ? desc(documentStagesOnlyView[item.id]) + : asc(documentStagesOnlyView[item.id]) + ) : [desc(documentStagesOnlyView.createdAt)] // 트랜잭션 실행 diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 7d41277e..f843862d 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -182,16 +182,6 @@ export function DocumentStagesTable({ // 필터 필드 정의 const filterFields: DataTableFilterField<DocumentStagesOnlyView>[] = [ - { - label: "문서번호", - value: "docNumber", - placeholder: "문서번호로 검색...", - }, - { - label: "제목", - value: "title", - placeholder: "제목으로 검색...", - }, ] const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesOnlyView>[] = [ diff --git a/lib/vendor-document-list/ship/import-from-dolce-button.tsx b/lib/vendor-document-list/ship/import-from-dolce-button.tsx index 90796d8e..1ffe466d 100644 --- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx +++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx @@ -25,6 +25,8 @@ import { SimplifiedDocumentsView } from "@/db/schema" import { ImportStatus } from "../import-service" import { useSession } from "next-auth/react" import { getProjectIdsByVendor } from "../service" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" // 🔥 API 응답 캐시 (컴포넌트 외부에 선언하여 인스턴스 간 공유) const statusCache = new Map<string, { data: ImportStatus; timestamp: number }>() @@ -69,6 +71,10 @@ export function ImportFromDOLCEButton({ const { data: session } = useSession() const vendorId = session?.user.companyId + const params = useParams() + const lng = (params?.lng as string) || "ko" + const { t } = useTranslation(lng, "engineering") + // 🔥 allDocuments에서 projectIds 추출 (props로 전달받은 경우 사용) const documentsProjectIds = React.useMemo(() => { if (propProjectIds) return propProjectIds // props로 받은 경우 그대로 사용 @@ -182,44 +188,34 @@ export function ImportFromDOLCEButton({ } catch (error) { console.error('Failed to fetch import statuses:', error) - toast.error('Unable to check status. Please verify project settings.') + toast.error(t('dolceImport.messages.statusCheckError')) } finally { setStatusLoading(false) } - }, [debouncedProjectIds, fetchImportStatusCached]) + }, [debouncedProjectIds, fetchImportStatusCached, t]) // 🔥 vendorId로 projects 가져오기 (최적화) React.useEffect(() => { - let isCancelled = false - - const fetchVendorProjects = async () => { - if (allDocuments.length === 0 && vendorId && !loadingVendorProjects) { - setLoadingVendorProjects(true) - try { - const projectIds = await getProjectIdsByVendor(vendorId) - if (!isCancelled) { - setVendorProjectIds(projectIds) - } - } catch (error) { - console.error('Failed to fetch vendor projects:', error) - if (!isCancelled) { - toast.error('Failed to fetch project information.') - } - } finally { - if (!isCancelled) { - setLoadingVendorProjects(false) - } - } - } - } - - fetchVendorProjects() - - return () => { - isCancelled = true - } - }, [allDocuments.length, vendorId, loadingVendorProjects]) - + let isCancelled = false; + + if (allDocuments.length !== 0 || !vendorId) return; + + setLoadingVendorProjects(true); + + getProjectIdsByVendor(vendorId) + .then((projectIds) => { + if (!isCancelled) setVendorProjectIds(projectIds); + }) + .catch((error) => { + if (!isCancelled) toast.error(t('dolceImport.messages.projectFetchError')); + }) + .finally(() => { + if (!isCancelled) setLoadingVendorProjects(false); + }); + + return () => { isCancelled = true; }; + }, [allDocuments, vendorId, t]); + // 🔥 컴포넌트 마운트 시 상태 조회 (디바운싱 적용) React.useEffect(() => { if (debouncedProjectIds.length > 0) { @@ -314,16 +310,21 @@ export function ImportFromDOLCEButton({ if (totalResult.success) { toast.success( - `DOLCE import completed`, + t('dolceImport.messages.importSuccess'), { - description: `New ${totalResult.newCount}, Updated ${totalResult.updatedCount}, Skipped ${totalResult.skippedCount} (${projectIds.length} projects)` + description: t('dolceImport.messages.importSuccessDescription', { + newCount: totalResult.newCount, + updatedCount: totalResult.updatedCount, + skippedCount: totalResult.skippedCount, + projectCount: projectIds.length + }) } ) } else { toast.error( - `DOLCE import partially failed`, + t('dolceImport.messages.importPartiallyFailed'), { - description: 'Some projects failed to import.' + description: t('dolceImport.messages.importPartiallyFailedDescription') } ) } @@ -338,35 +339,35 @@ export function ImportFromDOLCEButton({ setImportProgress(0) setIsImporting(false) - toast.error('DOLCE import failed', { - description: error instanceof Error ? error.message : 'An unknown error occurred.' + toast.error(t('dolceImport.messages.importFailed'), { + description: error instanceof Error ? error.message : t('dolceImport.messages.unknownError') }) } - }, [projectIds, fetchAllImportStatus, onImportComplete]) + }, [projectIds, fetchAllImportStatus, onImportComplete, t]) // 🔥 상태 뱃지 메모이제이션 const statusBadge = React.useMemo(() => { if (loadingVendorProjects) { - return <Badge variant="secondary">Loading project information...</Badge> + return <Badge variant="secondary">{t('dolceImport.status.loadingProjectInfo')}</Badge> } if (statusLoading) { - return <Badge variant="secondary">Checking DOLCE connection...</Badge> + return <Badge variant="secondary">{t('dolceImport.status.checkingConnection')}</Badge> } if (importStatusMap.size === 0) { - return <Badge variant="destructive">DOLCE Connection Error</Badge> + return <Badge variant="destructive">{t('dolceImport.status.connectionError')}</Badge> } if (!totalStats.importEnabled) { - return <Badge variant="secondary">DOLCE Import Disabled</Badge> + return <Badge variant="secondary">{t('dolceImport.status.importDisabled')}</Badge> } if (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) { return ( <Badge variant="samsung" className="gap-1"> <AlertTriangle className="w-3 h-3" /> - Updates Available ({projectIds.length} projects) + {t('dolceImport.status.updatesAvailable', { projectCount: projectIds.length })} </Badge> ) } @@ -374,10 +375,10 @@ export function ImportFromDOLCEButton({ return ( <Badge variant="default" className="gap-1 bg-green-500 hover:bg-green-600"> <CheckCircle className="w-3 h-3" /> - Synchronized with DOLCE + {t('dolceImport.status.synchronized')} </Badge> ) - }, [loadingVendorProjects, statusLoading, importStatusMap.size, totalStats, projectIds.length]) + }, [loadingVendorProjects, statusLoading, importStatusMap.size, totalStats, projectIds.length, t]) const canImport = totalStats.importEnabled && (totalStats.newDocuments > 0 || totalStats.updatedDocuments > 0) @@ -389,7 +390,7 @@ export function ImportFromDOLCEButton({ }, [fetchAllImportStatus]) // 로딩 중이거나 projectIds가 없으면 버튼을 표시하지 않음 - if (loadingVendorProjects || projectIds.length === 0) { + if (projectIds.length === 0) { return null } @@ -409,7 +410,7 @@ export function ImportFromDOLCEButton({ ) : ( <Download className="w-4 h-4" /> )} - <span className="hidden sm:inline">Get List</span> + <span className="hidden sm:inline">{t('dolceImport.buttons.getList')}</span> {totalStats.newDocuments + totalStats.updatedDocuments > 0 && ( <Badge variant="samsung" @@ -425,9 +426,9 @@ export function ImportFromDOLCEButton({ <PopoverContent className="w-96"> <div className="space-y-4"> <div className="space-y-2"> - <h4 className="font-medium">DOLCE Import Status</h4> + <h4 className="font-medium">{t('dolceImport.labels.importStatus')}</h4> <div className="flex items-center justify-between"> - <span className="text-sm text-muted-foreground">Current Status</span> + <span className="text-sm text-muted-foreground">{t('dolceImport.labels.currentStatus')}</span> {statusBadge} </div> </div> @@ -435,17 +436,17 @@ export function ImportFromDOLCEButton({ {/* 프로젝트 소스 표시 */} {allDocuments.length === 0 && vendorProjectIds.length > 0 && ( <div className="text-xs text-blue-600 bg-blue-50 p-2 rounded"> - No documents found, importing from all projects. + {t('dolceImport.descriptions.noDocumentsImportAll')} </div> )} {/* 다중 프로젝트 정보 표시 */} {projectIds.length > 1 && ( <div className="text-sm"> - <div className="text-muted-foreground">Target Projects</div> - <div className="font-medium">{projectIds.length} projects</div> + <div className="text-muted-foreground">{t('dolceImport.labels.targetProjects')}</div> + <div className="font-medium">{t('dolceImport.labels.projectCount', { count: projectIds.length })}</div> <div className="text-xs text-muted-foreground"> - Project IDs: {projectIds.join(', ')} + {t('dolceImport.labels.projectIds')}: {projectIds.join(', ')} </div> </div> )} @@ -456,17 +457,17 @@ export function ImportFromDOLCEButton({ <div className="grid grid-cols-2 gap-4 text-sm"> <div> - <div className="text-muted-foreground">New Documents</div> + <div className="text-muted-foreground">{t('dolceImport.labels.newDocuments')}</div> <div className="font-medium">{totalStats.newDocuments || 0}</div> </div> <div> - <div className="text-muted-foreground">Updates</div> + <div className="text-muted-foreground">{t('dolceImport.labels.updates')}</div> <div className="font-medium">{totalStats.updatedDocuments || 0}</div> </div> </div> <div className="text-sm"> - <div className="text-muted-foreground">Total Documents (B3/B4/B5)</div> + <div className="text-muted-foreground">{t('dolceImport.labels.totalDocuments')}</div> <div className="font-medium">{totalStats.availableDocuments || 0}</div> </div> @@ -474,20 +475,23 @@ export function ImportFromDOLCEButton({ {projectIds.length > 1 && ( <details className="text-sm"> <summary className="cursor-pointer text-muted-foreground hover:text-foreground"> - Details by Project + {t('dolceImport.labels.detailsByProject')} </summary> <div className="mt-2 space-y-2 pl-2 border-l-2 border-muted"> {projectIds.map(projectId => { const status = importStatusMap.get(projectId) return ( <div key={projectId} className="text-xs"> - <div className="font-medium">Project {projectId}</div> + <div className="font-medium">{t('dolceImport.labels.projectLabel', { projectId })}</div> {status ? ( <div className="text-muted-foreground"> - New {status.newDocuments}, Updates {status.updatedDocuments} + {t('dolceImport.descriptions.projectDetails', { + newDocuments: status.newDocuments, + updatedDocuments: status.updatedDocuments + })} </div> ) : ( - <div className="text-destructive">Status check failed</div> + <div className="text-destructive">{t('dolceImport.status.statusCheckFailed')}</div> )} </div> ) @@ -510,12 +514,12 @@ export function ImportFromDOLCEButton({ {isImporting ? ( <> <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - Importing... + {t('dolceImport.buttons.importing')} </> ) : ( <> <Download className="w-4 h-4 mr-2" /> - Import Now + {t('dolceImport.buttons.importNow')} </> )} </Button> @@ -541,10 +545,10 @@ export function ImportFromDOLCEButton({ <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <DialogContent className="sm:max-w-md"> <DialogHeader> - <DialogTitle>Import Document List from DOLCE</DialogTitle> + <DialogTitle>{t('dolceImport.dialog.title')}</DialogTitle> <DialogDescription> - Import the latest document list from Samsung Heavy Industries DOLCE system. - {projectIds.length > 1 && ` (${projectIds.length} projects targeted)`} + {t('dolceImport.dialog.description')} + {projectIds.length > 1 && ` (${t('dolceImport.dialog.multipleProjects', { count: projectIds.length })})`} </DialogDescription> </DialogHeader> @@ -552,20 +556,20 @@ export function ImportFromDOLCEButton({ {totalStats && ( <div className="rounded-lg border p-4 space-y-3"> <div className="flex items-center justify-between text-sm"> - <span>Items to Import</span> + <span>{t('dolceImport.labels.itemsToImport')}</span> <span className="font-medium"> {totalStats.newDocuments + totalStats.updatedDocuments} </span> </div> <div className="text-xs text-muted-foreground"> - Includes new and updated documents (B3, B4, B5). + {t('dolceImport.descriptions.includesNewAndUpdated')} <br /> - For B4 documents, GTTPreDwg and GTTWorkingDwg issue stages will be auto-generated. + {t('dolceImport.descriptions.b4DocumentsNote')} {projectIds.length > 1 && ( <> <br /> - Will import sequentially from {projectIds.length} projects. + {t('dolceImport.descriptions.sequentialImport', { count: projectIds.length })} </> )} </div> @@ -573,7 +577,7 @@ export function ImportFromDOLCEButton({ {isImporting && ( <div className="space-y-2"> <div className="flex items-center justify-between text-sm"> - <span>Progress</span> + <span>{t('dolceImport.labels.progress')}</span> <span>{importProgress}%</span> </div> <Progress value={importProgress} className="h-2" /> @@ -588,7 +592,7 @@ export function ImportFromDOLCEButton({ onClick={() => setIsDialogOpen(false)} disabled={isImporting} > - Cancel + {t('buttons.cancel')} </Button> <Button onClick={handleImport} @@ -597,12 +601,12 @@ export function ImportFromDOLCEButton({ {isImporting ? ( <> <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - Importing... + {t('dolceImport.buttons.importing')} </> ) : ( <> <Download className="w-4 h-4 mr-2" /> - Start Import + {t('dolceImport.buttons.startImport')} </> )} </Button> diff --git a/lib/vendor-document-list/ship/send-to-shi-button.tsx b/lib/vendor-document-list/ship/send-to-shi-button.tsx index 7236dfde..447b461b 100644 --- a/lib/vendor-document-list/ship/send-to-shi-button.tsx +++ b/lib/vendor-document-list/ship/send-to-shi-button.tsx @@ -26,6 +26,8 @@ import { Alert, AlertDescription } from "@/components/ui/alert" // ✅ 업데이트된 Hook import import { useClientSyncStatus, useTriggerSync, syncUtils } from "@/hooks/use-sync-status" import type { EnhancedDocument } from "@/types/enhanced-documents" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" interface SendToSHIButtonProps { documents?: EnhancedDocument[] @@ -42,6 +44,10 @@ export function SendToSHIButton({ const [syncProgress, setSyncProgress] = React.useState(0) const [currentSyncingContract, setCurrentSyncingContract] = React.useState<number | null>(null) + const params = useParams() + const lng = (params?.lng as string) || "ko" + const { t } = useTranslation(lng, "engineering") + const targetSystem = projectType === 'ship' ? "DOLCE" : "SWP" // 문서에서 유효한 계약 ID 목록 추출 (projectId 사용) @@ -80,7 +86,7 @@ export function SendToSHIButton({ // 동기화 실행 함수 const handleSync = async () => { if (documentsContractIds.length === 0) { - toast.info('동기화할 계약이 없습니다.') + toast.info(t('shiSync.messages.noContractsToSync')) return } @@ -105,7 +111,7 @@ export function SendToSHIButton({ }) if (contractsToSync.length === 0) { - toast.info('동기화할 변경사항이 없습니다.') + toast.info(t('shiSync.messages.noPendingChanges')) setIsDialogOpen(false) return } @@ -132,13 +138,13 @@ export function SendToSHIButton({ failedSyncs++ totalFailureCount += result?.failureCount || 0 const errorMsg = result?.errors?.[0] || result?.message || 'Unknown sync error' - errors.push(`Contract ${projectId}: ${errorMsg}`) + errors.push(t('shiSync.messages.contractError', { projectId, error: errorMsg })) console.error(`Contract ${projectId} sync failed:`, result) } } catch (error) { failedSyncs++ - const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류' - errors.push(`Contract ${projectId}: ${errorMessage}`) + const errorMessage = error instanceof Error ? error.message : t('shiSync.messages.unknownError') + errors.push(t('shiSync.messages.contractError', { projectId, error: errorMessage })) console.error(`Contract ${projectId} sync exception:`, error) } @@ -155,23 +161,30 @@ export function SendToSHIButton({ if (failedSyncs === 0) { toast.success( - `모든 계약 동기화 완료: ${totalSuccessCount}건 성공`, + t('shiSync.messages.allSyncCompleted', { successCount: totalSuccessCount }), { - description: `${successfulSyncs}개 계약에서 ${totalSuccessCount}개 항목이 SHI 시스템으로 전송되었습니다.` + description: t('shiSync.messages.allSyncCompletedDescription', { + contractCount: successfulSyncs, + itemCount: totalSuccessCount + }) } ) } else if (successfulSyncs > 0) { toast.warning( - `부분 동기화 완료: ${successfulSyncs}개 성공, ${failedSyncs}개 실패`, + t('shiSync.messages.partialSyncCompleted', { + successfulCount: successfulSyncs, + failedCount: failedSyncs + }), { - description: errors.slice(0, 3).join(', ') + (errors.length > 3 ? ' 외 더보기...' : '') + description: errors.slice(0, 3).join(', ') + + (errors.length > 3 ? t('shiSync.messages.andMore') : '') } ) } else { toast.error( - `동기화 실패: ${failedSyncs}개 계약 모두 실패`, + t('shiSync.messages.allSyncFailed', { failedCount: failedSyncs }), { - description: errors[0] || '모든 계약 동기화에 실패했습니다.' + description: errors[0] || t('shiSync.messages.allContractsSyncFailed') } ) } @@ -186,7 +199,7 @@ export function SendToSHIButton({ setCurrentSyncingContract(null) const errorMessage = syncUtils.formatError(error as Error) - toast.error('동기화 실패', { + toast.error(t('shiSync.messages.syncFailed'), { description: errorMessage }) console.error('Sync process failed:', error) @@ -199,7 +212,7 @@ export function SendToSHIButton({ return ( <Badge variant="secondary" className="gap-1"> <Loader2 className="w-3 h-3 animate-spin" /> - 확인 중... + {t('shiSync.status.checking')} </Badge> ) } @@ -208,20 +221,20 @@ export function SendToSHIButton({ return ( <Badge variant="destructive" className="gap-1"> <AlertTriangle className="w-3 h-3" /> - 연결 오류 + {t('shiSync.status.connectionError')} </Badge> ) } if (documentsContractIds.length === 0) { - return <Badge variant="secondary">계약 없음</Badge> + return <Badge variant="secondary">{t('shiSync.status.noContracts')}</Badge> } if (totalStats.totalPending > 0) { return ( <Badge variant="destructive" className="gap-1"> <AlertTriangle className="w-3 h-3" /> - {totalStats.totalPending}건 대기 + {t('shiSync.status.pendingItems', { count: totalStats.totalPending })} </Badge> ) } @@ -230,12 +243,12 @@ export function SendToSHIButton({ return ( <Badge variant="default" className="gap-1 bg-green-500 hover:bg-green-600"> <CheckCircle className="w-3 h-3" /> - 동기화됨 + {t('shiSync.status.synchronized')} </Badge> ) } - return <Badge variant="secondary">변경사항 없음</Badge> + return <Badge variant="secondary">{t('shiSync.status.noChanges')}</Badge> } return ( @@ -254,7 +267,7 @@ export function SendToSHIButton({ ) : ( <Send className="w-4 h-4" /> )} - <span className="hidden sm:inline">Send to SHI</span> + <span className="hidden sm:inline">{t('shiSync.buttons.sendToSHI')}</span> {totalStats.totalPending > 0 && ( <Badge variant="destructive" @@ -271,7 +284,7 @@ export function SendToSHIButton({ <div className="space-y-4"> <div className="space-y-2"> <div className="flex items-center justify-between"> - <h4 className="font-medium">SHI 동기화 상태</h4> + <h4 className="font-medium">{t('shiSync.labels.syncStatus')}</h4> <Button variant="ghost" size="sm" @@ -288,12 +301,15 @@ export function SendToSHIButton({ </div> <div className="flex items-center justify-between"> - <span className="text-sm text-muted-foreground">전체 상태</span> + <span className="text-sm text-muted-foreground">{t('shiSync.labels.overallStatus')}</span> {getSyncStatusBadge()} </div> <div className="text-xs text-muted-foreground"> - {documentsContractIds.length}개 계약 대상 • {targetSystem} 시스템 + {t('shiSync.descriptions.targetInfo', { + contractCount: documentsContractIds.length, + targetSystem + })} </div> </div> @@ -302,10 +318,12 @@ export function SendToSHIButton({ <Alert variant="destructive"> <AlertTriangle className="h-4 w-4" /> <AlertDescription> - 일부 계약의 동기화 상태를 확인할 수 없습니다. 네트워크 연결을 확인해주세요. + {t('shiSync.descriptions.statusCheckError')} {process.env.NODE_ENV === 'development' && ( <div className="text-xs mt-1 font-mono"> - Debug: {contractStatuses.filter(({ error }) => error).length}개 계약에서 오류 + Debug: {t('shiSync.descriptions.contractsWithError', { + count: contractStatuses.filter(({ error }) => error).length + })} </div> )} </AlertDescription> @@ -319,46 +337,46 @@ export function SendToSHIButton({ <div className="grid grid-cols-3 gap-4 text-sm"> <div className="text-center"> - <div className="text-muted-foreground">대기 중</div> - <div className="font-medium text-orange-600">{totalStats.totalPending}건</div> + <div className="text-muted-foreground">{t('shiSync.labels.pending')}</div> + <div className="font-medium text-orange-600">{t('shiSync.labels.itemCount', { count: totalStats.totalPending })}</div> </div> <div className="text-center"> - <div className="text-muted-foreground">동기화됨</div> - <div className="font-medium text-green-600">{totalStats.totalSynced}건</div> + <div className="text-muted-foreground">{t('shiSync.labels.synced')}</div> + <div className="font-medium text-green-600">{t('shiSync.labels.itemCount', { count: totalStats.totalSynced })}</div> </div> <div className="text-center"> - <div className="text-muted-foreground">실패</div> - <div className="font-medium text-red-600">{totalStats.totalFailed}건</div> + <div className="text-muted-foreground">{t('shiSync.labels.failed')}</div> + <div className="font-medium text-red-600">{t('shiSync.labels.itemCount', { count: totalStats.totalFailed })}</div> </div> </div> {/* 계약별 상세 상태 */} {contractStatuses.length > 1 && ( <div className="space-y-2"> - <div className="text-sm font-medium">계약별 상태</div> + <div className="text-sm font-medium">{t('shiSync.labels.statusByContract')}</div> <ScrollArea className="h-32"> <div className="space-y-2"> {contractStatuses.map(({ projectId, syncStatus, isLoading, error }) => ( <div key={projectId} className="flex items-center justify-between text-xs p-2 rounded border"> - <span className="font-medium">Contract {projectId}</span> + <span className="font-medium">{t('shiSync.labels.contractLabel', { projectId })}</span> {isLoading ? ( <Badge variant="secondary" className="text-xs"> <Loader2 className="w-3 h-3 mr-1 animate-spin" /> - 로딩... + {t('shiSync.status.loading')} </Badge> ) : error ? ( <Badge variant="destructive" className="text-xs"> <AlertTriangle className="w-3 h-3 mr-1" /> - 오류 + {t('shiSync.status.error')} </Badge> ) : syncStatus && syncStatus.pendingChanges > 0 ? ( <Badge variant="destructive" className="text-xs"> - {syncStatus.pendingChanges}건 대기 + {t('shiSync.status.pendingCount', { count: syncStatus.pendingChanges })} </Badge> ) : ( <Badge variant="secondary" className="text-xs"> <CheckCircle className="w-3 h-3 mr-1" /> - 최신 + {t('shiSync.status.upToDate')} </Badge> )} </div> @@ -374,7 +392,7 @@ export function SendToSHIButton({ {documentsContractIds.length === 0 && ( <Alert> <AlertDescription> - 동기화할 문서가 없습니다. 문서를 선택해주세요. + {t('shiSync.descriptions.noDocumentsToSync')} </AlertDescription> </Alert> )} @@ -392,12 +410,12 @@ export function SendToSHIButton({ {isSyncing ? ( <> <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - 동기화 중... + {t('shiSync.buttons.syncing')} </> ) : ( <> <Send className="w-4 h-4 mr-2" /> - 지금 동기화 + {t('shiSync.buttons.syncNow')} </> )} </Button> @@ -426,10 +444,13 @@ export function SendToSHIButton({ <DialogHeader> <DialogTitle className="flex items-center gap-2"> <Send className="w-5 h-5" /> - SHI 시스템으로 동기화 + {t('shiSync.dialog.title')} </DialogTitle> <DialogDescription> - {documentsContractIds.length}개 계약의 변경된 문서 데이터를 {targetSystem} 시스템으로 전송합니다. + {t('shiSync.dialog.description', { + contractCount: documentsContractIds.length, + targetSystem + })} </DialogDescription> </DialogHeader> @@ -437,30 +458,30 @@ export function SendToSHIButton({ {!totalStats.hasError && documentsContractIds.length > 0 && ( <div className="rounded-lg border p-4 space-y-3"> <div className="flex items-center justify-between text-sm"> - <span>전송 대상</span> - <span className="font-medium">{totalStats.totalPending}건</span> + <span>{t('shiSync.labels.syncTarget')}</span> + <span className="font-medium">{t('shiSync.labels.itemCount', { count: totalStats.totalPending })}</span> </div> <div className="flex items-center justify-between text-sm"> - <span>대상 계약</span> - <span className="font-medium">{documentsContractIds.length}개</span> + <span>{t('shiSync.labels.targetContracts')}</span> + <span className="font-medium">{t('shiSync.labels.contractCount', { count: documentsContractIds.length })}</span> </div> <div className="text-xs text-muted-foreground"> - 문서, 리비전, 첨부파일의 변경사항이 포함됩니다. + {t('shiSync.descriptions.includesChanges')} </div> {isSyncing && ( <div className="space-y-2"> <div className="flex items-center justify-between text-sm"> - <span>진행률</span> + <span>{t('shiSync.labels.progress')}</span> <span>{Math.round(syncProgress)}%</span> </div> <Progress value={syncProgress} className="h-2" /> {currentSyncingContract && ( <div className="text-xs text-muted-foreground flex items-center gap-1"> <Loader2 className="w-3 h-3 animate-spin" /> - 현재 처리 중: Contract {currentSyncingContract} + {t('shiSync.descriptions.currentlyProcessing', { contractId: currentSyncingContract })} </div> )} </div> @@ -472,7 +493,7 @@ export function SendToSHIButton({ <Alert variant="destructive"> <AlertTriangle className="h-4 w-4" /> <AlertDescription> - 일부 계약의 동기화 상태를 확인할 수 없습니다. 네트워크 연결을 확인하고 다시 시도해주세요. + {t('shiSync.descriptions.dialogStatusCheckError')} </AlertDescription> </Alert> )} @@ -480,7 +501,7 @@ export function SendToSHIButton({ {documentsContractIds.length === 0 && ( <Alert> <AlertDescription> - 동기화할 계약이 없습니다. 문서를 선택해주세요. + {t('shiSync.descriptions.noContractsToSync')} </AlertDescription> </Alert> )} @@ -491,7 +512,7 @@ export function SendToSHIButton({ onClick={() => setIsDialogOpen(false)} disabled={isSyncing} > - 취소 + {t('buttons.cancel')} </Button> <Button onClick={handleSync} @@ -500,12 +521,12 @@ export function SendToSHIButton({ {isSyncing ? ( <> <Loader2 className="w-4 h-4 mr-2 animate-spin" /> - 동기화 중... + {t('shiSync.buttons.syncing')} </> ) : ( <> <Send className="w-4 h-4 mr-2" /> - 동기화 시작 + {t('shiSync.buttons.startSync')} </> )} </Button> |
