summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-11 09:02:00 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-11 09:02:00 +0000
commitcbb4c7fe0b94459162ad5e998bc05cd293e0ff96 (patch)
tree0a26712f7685e4f6511e637b9a81269d90a47c8f /lib
parenteb654f88214095f71be142b989e620fd28db3f69 (diff)
(대표님) 입찰, EDP 변경사항 대응, spreadJS 오류 수정, 벤더실사 수정
Diffstat (limited to 'lib')
-rw-r--r--lib/bidding/bidding-notice-editor.tsx230
-rw-r--r--lib/bidding/list/biddings-page-header.tsx41
-rw-r--r--lib/bidding/list/biddings-stats-cards.tsx122
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx578
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx143
-rw-r--r--lib/bidding/list/biddings-table.tsx135
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx1674
-rw-r--r--lib/bidding/list/edit-bidding-sheet.tsx505
-rw-r--r--lib/bidding/service.ts815
-rw-r--r--lib/bidding/validation.ts157
-rw-r--r--lib/forms/services.ts16
-rw-r--r--lib/pq/helper.ts2
-rw-r--r--lib/pq/service.ts3
-rw-r--r--lib/sedp/get-form-tags.ts41
-rw-r--r--lib/sedp/get-tags.ts55
-rw-r--r--lib/sedp/sync-form.ts61
-rw-r--r--lib/tags/service.ts66
-rw-r--r--lib/tags/table/add-tag-dialog.tsx46
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts11
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx892
-rw-r--r--lib/vendor-document-list/plant/document-stages-expanded-content.tsx98
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts314
-rw-r--r--lib/vendor-document-list/plant/document-stages-table.tsx10
-rw-r--r--lib/vendor-document-list/ship/import-from-dolce-button.tsx152
-rw-r--r--lib/vendor-document-list/ship/send-to-shi-button.tsx127
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>