summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2025-11-10 11:25:19 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2025-11-10 11:25:19 +0900
commita5501ad1d1cb836d2b2f84e9b0f06049e22c901e (patch)
tree667ed8c5d6ec35b109190e9f976d66ae54def4ce
parentb0fe980376fcf1a19ff4b90851ca8b01f378fdc0 (diff)
parentf8a38907911d940cb2e8e6c9aa49488d05b2b578 (diff)
Merge remote-tracking branch 'origin/dujinkim' into master_homemaster
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx39
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx39
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx39
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/bidding-tabs.tsx77
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx75
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/detail/page.tsx51
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx75
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx76
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/layout.tsx92
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/page.tsx12
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/pre-quote/page.tsx56
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx73
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx78
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bidding-notice/page.tsx28
-rw-r--r--app/[lng]/partners/(partners)/bid/[id]/page.tsx2
-rw-r--r--app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx97
-rw-r--r--app/[lng]/partners/(partners)/bid/page.tsx7
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/[contractId]/page.tsx62
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx423
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/page.tsx48
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/vendor-general-contract-review-table.tsx142
-rw-r--r--components/bidding/bidding-conditions-edit.tsx469
-rw-r--r--components/bidding/bidding-info-header.tsx217
-rw-r--r--components/bidding/bidding-round-actions.tsx201
-rw-r--r--components/bidding/create/bidding-create-dialog.tsx1281
-rw-r--r--components/bidding/manage/bidding-basic-info-editor.tsx1407
-rw-r--r--components/bidding/manage/bidding-companies-editor.tsx803
-rw-r--r--components/bidding/manage/bidding-detail-vendor-create-dialog.tsx (renamed from lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx)219
-rw-r--r--components/bidding/manage/bidding-items-editor.tsx1143
-rw-r--r--components/bidding/manage/bidding-schedule-editor.tsx661
-rw-r--r--components/bidding/manage/create-pre-quote-rfq-dialog.tsx742
-rw-r--r--components/bidding/price-adjustment-dialog.tsx6
-rw-r--r--components/common/selectors/cost-center/cost-center-selector.tsx335
-rw-r--r--components/common/selectors/cost-center/cost-center-service.ts89
-rw-r--r--components/common/selectors/cost-center/cost-center-single-selector.tsx378
-rw-r--r--components/common/selectors/cost-center/index.ts12
-rw-r--r--components/common/selectors/gl-account/gl-account-selector.tsx311
-rw-r--r--components/common/selectors/gl-account/gl-account-service.ts79
-rw-r--r--components/common/selectors/gl-account/gl-account-single-selector.tsx358
-rw-r--r--components/common/selectors/gl-account/index.ts12
-rw-r--r--components/common/selectors/wbs-code/index.ts12
-rw-r--r--components/common/selectors/wbs-code/wbs-code-selector.tsx323
-rw-r--r--components/common/selectors/wbs-code/wbs-code-service.ts92
-rw-r--r--components/common/selectors/wbs-code/wbs-code-single-selector.tsx365
-rw-r--r--components/layout/HeaderSimple.tsx9
-rw-r--r--components/ship-vendor-document/add-attachment-dialog.tsx55
-rw-r--r--components/ship-vendor-document/new-revision-dialog.tsx51
-rw-r--r--db/schema/bidding.ts958
-rw-r--r--db/schema/generalContract.ts18
-rw-r--r--i18n/locales/en/menu.json7
-rw-r--r--i18n/locales/ko/menu.json6
-rw-r--r--lib/bidding/actions.ts1065
-rw-r--r--lib/bidding/bidding-notice-editor.tsx99
-rw-r--r--lib/bidding/bidding-notice-template-manager.tsx63
-rw-r--r--lib/bidding/detail/bidding-actions.ts227
-rw-r--r--lib/bidding/detail/service.ts160
-rw-r--r--lib/bidding/detail/table/bidding-detail-content.tsx201
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-columns.tsx23
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx8
-rw-r--r--lib/bidding/detail/table/bidding-invitation-dialog.tsx718
-rw-r--r--lib/bidding/failure/biddings-failure-columns.tsx320
-rw-r--r--lib/bidding/failure/biddings-failure-table.tsx223
-rw-r--r--lib/bidding/list/bidding-detail-dialogs.tsx554
-rw-r--r--lib/bidding/list/bidding-pr-documents-dialog.tsx405
-rw-r--r--lib/bidding/list/biddings-table-columns.tsx649
-rw-r--r--lib/bidding/list/biddings-table-toolbar-actions.tsx64
-rw-r--r--lib/bidding/list/biddings-table.tsx30
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx4230
-rw-r--r--lib/bidding/pre-quote/service.ts3145
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx224
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx51
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx770
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx125
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx157
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx398
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx311
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx200
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx257
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx130
-rw-r--r--lib/bidding/receive/biddings-receive-columns.tsx360
-rw-r--r--lib/bidding/receive/biddings-receive-table.tsx211
-rw-r--r--lib/bidding/selection/biddings-selection-columns.tsx289
-rw-r--r--lib/bidding/selection/biddings-selection-table.tsx218
-rw-r--r--lib/bidding/service.ts2841
-rw-r--r--lib/bidding/validation.ts170
-rw-r--r--lib/bidding/vendor/components/pr-items-pricing-table.tsx27
-rw-r--r--lib/bidding/vendor/partners-bidding-detail.tsx904
-rw-r--r--lib/bidding/vendor/partners-bidding-list-columns.tsx104
-rw-r--r--lib/bidding/vendor/partners-bidding-pre-quote.tsx1413
-rw-r--r--lib/file-stroage.ts12
-rw-r--r--lib/general-contracts/detail/general-contract-approval-request-dialog.tsx141
-rw-r--r--lib/general-contracts/detail/general-contract-basic-info.tsx488
-rw-r--r--lib/general-contracts/detail/general-contract-detail.tsx81
-rw-r--r--lib/general-contracts/detail/general-contract-documents.tsx11
-rw-r--r--lib/general-contracts/detail/general-contract-info-header.tsx5
-rw-r--r--lib/general-contracts/detail/general-contract-items-table.tsx292
-rw-r--r--lib/general-contracts/detail/general-contract-review-comments.tsx194
-rw-r--r--lib/general-contracts/detail/general-contract-review-request-dialog.tsx891
-rw-r--r--lib/general-contracts/detail/general-contract-storage-info.tsx249
-rw-r--r--lib/general-contracts/detail/general-contract-subcontract-checklist.tsx47
-rw-r--r--lib/general-contracts/detail/general-contract-yard-entry-info.tsx232
-rw-r--r--lib/general-contracts/main/create-general-contract-dialog.tsx156
-rw-r--r--lib/general-contracts/main/general-contract-update-sheet.tsx53
-rw-r--r--lib/general-contracts/main/general-contracts-table-columns.tsx34
-rw-r--r--lib/general-contracts/main/general-contracts-table.tsx5
-rw-r--r--lib/general-contracts/service.ts1102
-rw-r--r--lib/general-contracts/types.ts8
-rw-r--r--lib/general-contracts_old/detail/general-contract-approval-request-dialog.tsx1312
-rw-r--r--lib/general-contracts_old/detail/general-contract-basic-info.tsx1250
-rw-r--r--lib/general-contracts_old/detail/general-contract-communication-channel.tsx (renamed from lib/general-contracts/detail/general-contract-communication-channel.tsx)0
-rw-r--r--lib/general-contracts_old/detail/general-contract-detail.tsx186
-rw-r--r--lib/general-contracts_old/detail/general-contract-documents.tsx383
-rw-r--r--lib/general-contracts_old/detail/general-contract-field-service-rate.tsx (renamed from lib/general-contracts/detail/general-contract-field-service-rate.tsx)0
-rw-r--r--lib/general-contracts_old/detail/general-contract-info-header.tsx211
-rw-r--r--lib/general-contracts_old/detail/general-contract-items-table.tsx602
-rw-r--r--lib/general-contracts_old/detail/general-contract-location.tsx (renamed from lib/general-contracts/detail/general-contract-location.tsx)0
-rw-r--r--lib/general-contracts_old/detail/general-contract-offset-details.tsx (renamed from lib/general-contracts/detail/general-contract-offset-details.tsx)0
-rw-r--r--lib/general-contracts_old/detail/general-contract-subcontract-checklist.tsx610
-rw-r--r--lib/general-contracts_old/main/create-general-contract-dialog.tsx413
-rw-r--r--lib/general-contracts_old/main/general-contract-update-sheet.tsx401
-rw-r--r--lib/general-contracts_old/main/general-contracts-table-columns.tsx571
-rw-r--r--lib/general-contracts_old/main/general-contracts-table-toolbar-actions.tsx124
-rw-r--r--lib/general-contracts_old/main/general-contracts-table.tsx217
-rw-r--r--lib/general-contracts_old/service.ts1933
-rw-r--r--lib/general-contracts_old/types.ts125
-rw-r--r--lib/general-contracts_old/validation.ts82
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts33
-rw-r--r--lib/vendor-document-list/service.ts36
-rw-r--r--lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx51
-rw-r--r--lib/vendor-document-list/ship/import-from-dolce-button.tsx126
130 files changed, 34485 insertions, 11736 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx
new file mode 100644
index 00000000..f460f570
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx
@@ -0,0 +1,39 @@
+import { Metadata } from 'next'
+import { getBiddingsForFailure } from '@/lib/bidding/service'
+import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation'
+import { BiddingsFailureTable } from '@/lib/bidding/failure/biddings-failure-table'
+
+export const metadata: Metadata = {
+ title: '유찰입찰',
+ description: '유찰된 입찰 내역을 확인하고 재입찰을 진행할 수 있습니다.',
+}
+
+interface BiddingFailurePageProps {
+ searchParams: Promise<Record<string, string | string[] | undefined>>
+}
+
+export default async function BiddingFailurePage({
+ searchParams,
+}: BiddingFailurePageProps) {
+ // URL 파라미터 검증
+ const searchParamsResolved = await searchParams
+ const search = searchParamsCache.parse(searchParamsResolved)
+
+ // 데이터 조회
+ const biddingsPromise = getBiddingsForFailure(search)
+
+ return (
+ <div className="flex flex-col gap-4 p-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold tracking-tight">유찰입찰</h1>
+ <p className="text-muted-foreground">
+ 유찰된 입찰 내역을 확인하고 재입찰을 진행할 수 있습니다.
+ </p>
+ </div>
+ </div>
+
+ <BiddingsFailureTable promises={Promise.all([biddingsPromise])} />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx
new file mode 100644
index 00000000..0d725bbf
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx
@@ -0,0 +1,39 @@
+import { Metadata } from 'next'
+import { getBiddingsForReceive } from '@/lib/bidding/service'
+import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation'
+import { BiddingsReceiveTable } from '@/lib/bidding/receive/biddings-receive-table'
+
+export const metadata: Metadata = {
+ title: '입찰서접수및마감',
+ description: '입찰서 접수 및 마감 현황을 확인하고 개찰을 진행할 수 있습니다.',
+}
+
+interface BiddingReceivePageProps {
+ searchParams: Promise<Record<string, string | string[] | undefined>>
+}
+
+export default async function BiddingReceivePage({
+ searchParams,
+}: BiddingReceivePageProps) {
+ // URL 파라미터 검증
+ const searchParamsResolved = await searchParams
+ const search = searchParamsCache.parse(searchParamsResolved)
+
+ // 데이터 조회
+ const biddingsPromise = getBiddingsForReceive(search)
+
+ return (
+ <div className="flex flex-col gap-4 p-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold tracking-tight">입찰서접수및마감</h1>
+ <p className="text-muted-foreground">
+ 입찰서 접수 현황을 확인하고 개찰을 진행할 수 있습니다.
+ </p>
+ </div>
+ </div>
+
+ <BiddingsReceiveTable promises={Promise.all([biddingsPromise])} />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx
new file mode 100644
index 00000000..40b714de
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx
@@ -0,0 +1,39 @@
+import { Metadata } from 'next'
+import { getBiddingsForSelection } from '@/lib/bidding/service'
+import { GetBiddingsSchema, searchParamsCache } from '@/lib/bidding/validation'
+import { BiddingsSelectionTable } from '@/lib/bidding/selection/biddings-selection-table'
+
+export const metadata: Metadata = {
+ title: '입찰선정',
+ description: '개찰 이후 입찰가를 확인하고 낙찰업체를 선정할 수 있습니다.',
+}
+
+interface BiddingSelectionPageProps {
+ searchParams: Promise<Record<string, string | string[] | undefined>>
+}
+
+export default async function BiddingSelectionPage({
+ searchParams,
+}: BiddingSelectionPageProps) {
+ // URL 파라미터 검증
+ const searchParamsResolved = await searchParams
+ const search = searchParamsCache.parse(searchParamsResolved)
+
+ // 데이터 조회
+ const biddingsPromise = getBiddingsForSelection(search)
+
+ return (
+ <div className="flex flex-col gap-4 p-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-2xl font-bold tracking-tight">입찰선정</h1>
+ <p className="text-muted-foreground">
+ 개찰 이후 입찰가를 확인하고 낙찰업체를 선정할 수 있습니다.
+ </p>
+ </div>
+ </div>
+
+ <BiddingsSelectionTable promises={Promise.all([biddingsPromise])} />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/bidding-tabs.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/bidding-tabs.tsx
new file mode 100644
index 00000000..0321d273
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/bidding-tabs.tsx
@@ -0,0 +1,77 @@
+"use client"
+
+import * as React from "react"
+import { usePathname, useRouter } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { cn } from "@/lib/utils"
+
+interface BiddingTabsProps {
+ id: string
+}
+
+export function BiddingTabs({ id }: BiddingTabsProps) {
+ const pathname = usePathname()
+ const router = useRouter()
+
+ const tabs = React.useMemo(() => [
+ {
+ key: "info",
+ label: "입찰 기본 정보",
+ href: `/evcp/bid/${id}/info`,
+ },
+ {
+ key: "companies",
+ label: "입찰 업체",
+ href: `/evcp/bid/${id}/companies`,
+ },
+ {
+ key: "items",
+ label: "입찰 품목",
+ href: `/evcp/bid/${id}/items`,
+ },
+ {
+ key: "schedule",
+ label: "입찰 계획",
+ href: `/evcp/bid/${id}/schedule`,
+ },
+ ], [id])
+
+ // 현재 활성 탭 결정
+ const activeTab = React.useMemo(() => {
+ if (!pathname) return "info"
+
+ // pathname에서 lng 부분 제거 (예: /en/evcp/bid/10 -> /evcp/bid/10)
+ const normalizedPath = pathname.replace(/^\/[^/]+/, '') || pathname
+
+ // 기본 페이지는 info로 처리
+ if (normalizedPath === `/evcp/bid/${id}` || normalizedPath.endsWith(`/bid/${id}`)) {
+ return "info"
+ }
+
+ const matchedTab = tabs.find(tab => normalizedPath.includes(`/${tab.key}`))
+ return matchedTab?.key || "info"
+ }, [pathname, id, tabs])
+
+ return (
+ <div className="flex items-center gap-1">
+ {tabs.map((tab) => {
+ const isActive = activeTab === tab.key
+ return (
+ <Button
+ key={tab.key}
+ variant={isActive ? "secondary" : "ghost"}
+ size="default"
+ className={cn(
+ "text-md px-3 py-1 h-7",
+ isActive && "bg-secondary"
+ )}
+ onClick={() => router.push(tab.href)}
+ >
+ {tab.label}
+ </Button>
+ )
+ })}
+ </div>
+ )
+}
+
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx
new file mode 100644
index 00000000..f1699665
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx
@@ -0,0 +1,75 @@
+import { notFound } from 'next/navigation'
+import { getBiddingById } from "@/lib/bidding/service"
+import { Bidding } from "@/db/schema/bidding"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+import Link from "next/link"
+import { BiddingCompaniesEditor } from "@/components/bidding/manage/bidding-companies-editor"
+import { BiddingTabs } from "../bidding-tabs"
+
+// 메타데이터 생성
+export async function generateMetadata({ params }: { params: Promise<{ lng: string; id: string }> }) {
+ const { lng, id } = await params
+ const parsedId = parseInt(id)
+ if (isNaN(parsedId)) return { title: '입찰 업체 및 담당자 관리' }
+
+ try {
+ const bidding = await getBiddingById(parsedId)
+ return {
+ title: bidding ? `${bidding.title} - 입찰 업체 및 담당자 관리` : '입찰 업체 및 담당자 관리',
+ }
+ } catch {
+ return { title: '입찰 업체 및 담당자 관리' }
+ }
+}
+
+interface PageProps {
+ params: Promise<{ lng: string; id: string }>
+}
+
+export default async function BiddingCompaniesPage({ params }: PageProps) {
+ const { lng, id } = await params
+ const parsedId = parseInt(id)
+
+ if (isNaN(parsedId)) {
+ notFound()
+ }
+
+ const bidding: Bidding | null = await getBiddingById(parsedId)
+
+ if (!bidding) {
+ notFound()
+ }
+
+ return (
+ <div className="container py-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-4">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">
+ 입찰 업체 및 담당자 관리
+ </h1>
+ <p className="text-muted-foreground mt-2">
+ 입찰 No. {bidding.biddingNumber ?? ""} - {bidding.title}
+ </p>
+ </div>
+ </div>
+ <Link href={`/${lng}/evcp/bid/${id}`} passHref>
+ <Button variant="outline" className="flex items-center">
+ <ArrowLeft className="mr-2 h-4 w-4" />
+ 입찰 관리로 돌아가기
+ </Button>
+ </Link>
+ </div>
+
+ {/* 탭 네비게이션 */}
+ <div>
+ <BiddingTabs id={id} />
+ </div>
+
+ {/* 입찰 업체 및 담당자 에디터 */}
+ <BiddingCompaniesEditor biddingId={parsedId} />
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/detail/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/detail/page.tsx
deleted file mode 100644
index 4dc36e20..00000000
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/detail/page.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Suspense } from 'react'
-import { notFound } from 'next/navigation'
-import { getBiddingDetailData } from '@/lib/bidding/detail/service'
-import { BiddingDetailContent } from '@/lib/bidding/detail/table/bidding-detail-content'
-
-// 메타데이터 생성
-export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
- const { id } = await params
- const parsedId = parseInt(id)
- if (isNaN(parsedId)) return { title: '입찰 관리상세' }
-
- try {
- const detailData = await getBiddingDetailData(parsedId)
- return {
- title: detailData.bidding ? `${detailData.bidding.title} - 입찰 관리상세` : '입찰 관리상세',
- }
- } catch {
- return { title: '입찰 관리상세' }
- }
-}
-
-interface PageProps {
- params: Promise<{ id: string }>
-}
-
-export default async function Page({ params }: PageProps) {
- const { id } = await params
- const parsedId = parseInt(id)
-
- if (isNaN(parsedId)) {
- notFound()
- }
-
- // 통합 데이터 로딩 함수 사용
- const detailData = await getBiddingDetailData(parsedId)
-
- if (!detailData.bidding) {
- notFound()
- }
-
- return (
- <Suspense fallback={<div className="p-8">로딩 중...</div>}>
- <BiddingDetailContent
- bidding={detailData.bidding}
- quotationDetails={detailData.quotationDetails}
- quotationVendors={detailData.quotationVendors}
- prItems={detailData.prItems}
- />
- </Suspense>
- )
-}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx
new file mode 100644
index 00000000..7281d206
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx
@@ -0,0 +1,75 @@
+import { notFound } from 'next/navigation'
+import { getBiddingById } from "@/lib/bidding/service"
+import { Bidding } from "@/db/schema/bidding"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+import Link from "next/link"
+import { BiddingBasicInfoEditor } from "@/components/bidding/manage/bidding-basic-info-editor"
+import { BiddingTabs } from "../bidding-tabs"
+
+// 메타데이터 생성
+export async function generateMetadata({ params }: { params: Promise<{ lng: string; id: string }> }) {
+ const { id } = await params
+ const parsedId = parseInt(id)
+ if (isNaN(parsedId)) return { title: '입찰 기본 정보 관리' }
+
+ try {
+ const bidding = await getBiddingById(parsedId)
+ return {
+ title: bidding ? `${bidding.title} - 입찰 기본 정보 관리` : '입찰 기본 정보 관리',
+ }
+ } catch {
+ return { title: '입찰 기본 정보 관리' }
+ }
+}
+
+interface PageProps {
+ params: Promise<{ lng: string; id: string }>
+}
+
+export default async function BiddingBasicInfoPage({ params }: PageProps) {
+ const { lng, id } = await params
+ const parsedId = parseInt(id)
+
+ if (isNaN(parsedId)) {
+ notFound()
+ }
+
+ const bidding: Bidding | null = await getBiddingById(parsedId)
+
+ if (!bidding) {
+ notFound()
+ }
+
+ return (
+ <div className="container py-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-4">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">
+ 입찰 기본 정보 관리
+ </h1>
+ <p className="text-muted-foreground mt-2">
+ 입찰 No. {bidding.biddingNumber ?? ""} - {bidding.title}
+ </p>
+ </div>
+ </div>
+ <Link href={`/${lng}/evcp/bid/${id}`} passHref>
+ <Button variant="outline" className="flex items-center">
+ <ArrowLeft className="mr-2 h-4 w-4" />
+ 입찰 관리로 돌아가기
+ </Button>
+ </Link>
+ </div>
+
+ {/* 탭 네비게이션 */}
+ <div>
+ <BiddingTabs id={id} />
+ </div>
+
+ {/* 입찰 기본 정보 에디터 */}
+ <BiddingBasicInfoEditor biddingId={parsedId} />
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx
new file mode 100644
index 00000000..5b686a1c
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx
@@ -0,0 +1,76 @@
+import { notFound } from 'next/navigation'
+import { getBiddingById } from "@/lib/bidding/service"
+import { Bidding } from "@/db/schema/bidding"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+import Link from "next/link"
+import { BiddingItemsEditor } from "@/components/bidding/manage/bidding-items-editor"
+import { BiddingTabs } from "../bidding-tabs"
+
+// 메타데이터 생성
+export async function generateMetadata({ params }: { params: Promise<{ lng: string; id: string }> }) {
+ const { lng, id } = await params
+ const parsedId = parseInt(id)
+ if (isNaN(parsedId)) return { title: '입찰 품목 관리' }
+
+ try {
+ const bidding = await getBiddingById(parsedId)
+ return {
+ title: bidding ? `${bidding.title} - 입찰 품목 관리` : '입찰 품목 관리',
+ }
+ } catch {
+ return { title: '입찰 품목 관리' }
+ }
+}
+
+interface PageProps {
+ params: Promise<{ lng: string; id: string }>
+}
+
+export default async function BiddingItemsPage({ params }: PageProps) {
+ const { lng, id } = await params
+ const parsedId = parseInt(id)
+
+ if (isNaN(parsedId)) {
+ notFound()
+ }
+
+ const bidding: Bidding | null = await getBiddingById(parsedId)
+
+ if (!bidding) {
+ notFound()
+ }
+
+ return (
+ <div className="container py-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-4">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">
+ 입찰 품목 관리
+ </h1>
+ <p className="text-muted-foreground mt-2">
+ 입찰 No. {bidding.biddingNumber ?? ""} - {bidding.title}
+ </p>
+ </div>
+
+ </div>
+ <Link href={`/${lng}/evcp/bid/${id}`} passHref>
+ <Button variant="outline" className="flex items-center">
+ <ArrowLeft className="mr-2 h-4 w-4" />
+ 입찰 관리로 돌아가기
+ </Button>
+ </Link>
+ </div>
+
+ {/* 탭 네비게이션 */}
+ <div>
+ <BiddingTabs id={id} />
+ </div>
+
+ {/* 입찰 품목 에디터 */}
+ <BiddingItemsEditor biddingId={parsedId} />
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/layout.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/layout.tsx
deleted file mode 100644
index 80e7f8d2..00000000
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/layout.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import { Metadata } from "next"
-
-import { Separator } from "@/components/ui/separator"
-import { SidebarNav } from "@/components/layout/sidebar-nav"
-import { getBiddingById, getBiddingConditions } from "@/lib/bidding/service"
-import { Bidding } from "@/db/schema/bidding"
-import { Button } from "@/components/ui/button"
-import { ArrowLeft } from "lucide-react"
-import Link from "next/link"
-import { BiddingInfoHeader } from "@/components/bidding/bidding-info-header"
-import { BiddingConditionsEdit } from "@/components/bidding/bidding-conditions-edit"
-export const metadata: Metadata = {
- title: "Bidding Detail",
-}
-
-export default async function SettingsLayout({
- children,
- params,
-}: {
- children: React.ReactNode
- params: { lng: string , id: string}
-}) {
-
- // 1) URL 파라미터에서 id 추출, Number로 변환
- const resolvedParams = await params
- const lng = resolvedParams.lng
- const id = resolvedParams.id
-
- const idAsNumber = Number(id)
- // 2) DB에서 해당 입찰 정보 조회
- const bidding: Bidding | null = await getBiddingById(idAsNumber)
- const biddingConditions = await getBiddingConditions(idAsNumber)
-
- // 3) 사이드바 메뉴
- const sidebarNavItems = [
- {
- title: "입찰 사전견적",
- href: `/${lng}/evcp/bid/${id}/pre-quote`,
- },
- {
- title: "입찰 관리상세",
- href: `/${lng}/evcp/bid/${id}/detail`,
- },
- ]
-
- return (
- <>
- <div className="container py-6">
- <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
- <div className="hidden space-y-6 p-10 pb-16 md:block">
- {/* RFQ 목록으로 돌아가는 링크 추가 */}
- <div className="flex justify-between items-center mb-4">
- <div>
- {/* 4) 입찰 정보가 있으면 번호 + 제목 + "상세 정보" 표기 */}
- <h2 className="text-2xl font-bold tracking-tight">
- {bidding
- ? `입찰 No. ${bidding.biddingNumber ?? ""} - ${bidding.title}`
- : "Loading Bidding..."}
- </h2>
- </div>
- <Link href={`/${lng}/evcp/bid`} passHref>
- <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
- <ArrowLeft className="mr-1 h-4 w-4" />
- <span>입찰 목록으로 돌아가기</span>
- </Button>
- </Link>
- </div>
-
- {/* 입찰 정보 헤더 */}
- <BiddingInfoHeader bidding={bidding} />
-
- {/* 입찰 조건 */}
- {bidding && (
- <BiddingConditionsEdit
- biddingId={bidding.id}
- initialConditions={biddingConditions}
- />
- )}
-
- <Separator className="my-6" />
- <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
- <aside className="-mx-4 lg:w-1/5">
- <SidebarNav items={sidebarNavItems} />
- </aside>
- <div className="flex-1 overflow-auto max-w-full">{children}</div>
- </div>
- </div>
- </section>
- </div>
- </>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/page.tsx
deleted file mode 100644
index ca0788a5..00000000
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/page.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { redirect } from 'next/navigation'
-
-interface PageProps {
- params: Promise<{ lng: string; id: string }>
-}
-
-export default async function Page({ params }: PageProps) {
- const { lng, id } = await params
-
- // 기본적으로 입찰 사전견적 페이지로 리다이렉트
- redirect(`/${lng}/evcp/bid/${id}/pre-quote`)
-}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/pre-quote/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/pre-quote/page.tsx
deleted file mode 100644
index d978974b..00000000
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/pre-quote/page.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Suspense } from 'react'
-import { notFound } from 'next/navigation'
-import { getBiddingDetailData } from '@/lib/bidding/detail/service'
-import { getBiddingCompanies } from '@/lib/bidding/pre-quote/service'
-import { BiddingPreQuoteContent } from '@/lib/bidding/pre-quote/table/bidding-pre-quote-content'
-
-// 메타데이터 생성
-export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
- const { id } = await params
- const parsedId = parseInt(id)
- if (isNaN(parsedId)) return { title: '입찰 사전견적' }
-
- try {
- const detailData = await getBiddingDetailData(parsedId)
- return {
- title: detailData.bidding ? `${detailData.bidding.title} - 입찰 사전견적` : '입찰 사전견적',
- }
- } catch {
- return { title: '입찰 사전견적' }
- }
-}
-
-interface PageProps {
- params: Promise<{ id: string }>
-}
-
-export default async function Page({ params }: PageProps) {
- const { id } = await params
- const parsedId = parseInt(id)
-
- if (isNaN(parsedId)) {
- notFound()
- }
-
- // 통합 데이터 로딩 함수 사용
- const detailData = await getBiddingDetailData(parsedId)
-
- if (!detailData.bidding) {
- notFound()
- }
-
- // 사전견적용 입찰 업체들 조회
- const biddingCompaniesResult = await getBiddingCompanies(parsedId)
- const biddingCompanies = biddingCompaniesResult?.success ? biddingCompaniesResult.data || [] : []
-
- return (
- <Suspense fallback={<div className="p-8">로딩 중...</div>}>
- <BiddingPreQuoteContent
- bidding={detailData.bidding}
- quotationDetails={detailData.quotationDetails}
- biddingCompanies={biddingCompanies}
- prItems={detailData.prItems}
- />
- </Suspense>
- )
-}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx
new file mode 100644
index 00000000..a79bef88
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx
@@ -0,0 +1,73 @@
+import { notFound } from 'next/navigation'
+import { getBiddingById } from "@/lib/bidding/service"
+import { Bidding } from "@/db/schema/bidding"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+import Link from "next/link"
+import { BiddingScheduleEditor } from "@/components/bidding/manage/bidding-schedule-editor"
+import { BiddingTabs } from "../bidding-tabs"
+
+// 메타데이터 생성
+export async function generateMetadata({ params }: { params: Promise<{ lng: string; id: string }> }) {
+ const { lng, id } = await params
+ const parsedId = parseInt(id)
+ if (isNaN(parsedId)) return { title: '입찰 일정 관리' }
+
+ try {
+ const bidding = await getBiddingById(parsedId)
+ return {
+ title: bidding ? `${bidding.title} - 입찰 일정 관리` : '입찰 일정 관리',
+ }
+ } catch {
+ return { title: '입찰 일정 관리' }
+ }
+}
+
+interface PageProps {
+ params: Promise<{ lng: string; id: string }>
+}
+
+export default async function BiddingSchedulePage({ params }: PageProps) {
+ const { lng, id } = await params
+ const parsedId = parseInt(id)
+
+ if (isNaN(parsedId)) {
+ notFound()
+ }
+
+ const bidding: Bidding | null = await getBiddingById(parsedId)
+
+ if (!bidding) {
+ notFound()
+ }
+
+ return (
+ <div className="container py-6 space-y-6">
+ {/* 헤더 */}
+ <div className="flex justify-between items-center">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">
+ 입찰 일정 관리
+ </h1>
+ <p className="text-muted-foreground mt-2">
+ 입찰 No. {bidding.biddingNumber ?? ""} - {bidding.title}
+ </p>
+ </div>
+ <Link href={`/${lng}/evcp/bid/${id}`} passHref>
+ <Button variant="outline" className="flex items-center">
+ <ArrowLeft className="mr-2 h-4 w-4" />
+ 입찰 관리로 돌아가기
+ </Button>
+ </Link>
+ </div>
+
+ {/* 탭 네비게이션 */}
+ <div>
+ <BiddingTabs id={id} />
+ </div>
+
+ {/* 입찰 일정 에디터 */}
+ <BiddingScheduleEditor biddingId={parsedId} />
+ </div>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx
index aa9f33b5..973593d8 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx
@@ -4,14 +4,9 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import {
getBiddings,
getBiddingStatusCounts,
- getBiddingTypeCounts,
- getBiddingManagerCounts,
- getBiddingMonthlyStats,
- getUserCodeByEmail,
} from "@/lib/bidding/service"
import { searchParamsCache } from "@/lib/bidding/validation"
import { BiddingsPageHeader } from "@/lib/bidding/list/biddings-page-header"
-import { BiddingsStatsCards } from "@/lib/bidding/list/biddings-stats-cards"
import { BiddingsTable } from "@/lib/bidding/list/biddings-table"
import { getValidFilters } from "@/lib/data-table"
import { type SearchParams } from "@/types/table"
@@ -33,30 +28,13 @@ export default async function BiddingsPage(props: IndexPageProps) {
const validFilters = getValidFilters(search.filters)
- // ✅ 입찰 데이터를 먼저 가져옴
- const biddingsResult = await getBiddings({
- ...search,
- filters: validFilters,
- })
-
- // ✅ 입찰 데이터에 managerCode 추가
- const biddingsDataWithManagerCode = await Promise.all(
- biddingsResult.data.map(async (item) => {
- let managerCode: string | null = null
- if (item.managerEmail) {
- managerCode = await getUserCodeByEmail(item.managerEmail)
- }
- return { ...item, managerCode: managerCode || null }
- })
- )
-
// ✅ 모든 데이터를 병렬로 로드
const promises = Promise.all([
- Promise.resolve({ ...biddingsResult, data: biddingsDataWithManagerCode }),
+ getBiddings({
+ ...search,
+ filters: validFilters,
+ }),
getBiddingStatusCounts(),
- getBiddingTypeCounts(),
- getBiddingManagerCounts(),
- getBiddingMonthlyStats(),
])
return (
@@ -67,13 +45,6 @@ export default async function BiddingsPage(props: IndexPageProps) {
<BiddingsPageHeader />
{/* ═══════════════════════════════════════════════════════════════ */}
- {/* 통계 카드들 */}
- {/* ═══════════════════════════════════════════════════════════════ */}
- <Suspense fallback={<BiddingsStatsCardsSkeleton />}>
- <BiddingsStatsCardsWrapper promises={promises} />
- </Suspense>
-
- {/* ═══════════════════════════════════════════════════════════════ */}
{/* 메인 테이블 */}
{/* ═══════════════════════════════════════════════════════════════ */}
<Suspense
@@ -92,44 +63,3 @@ export default async function BiddingsPage(props: IndexPageProps) {
</Shell>
)
}
-
-// ═══════════════════════════════════════════════════════════════
-// 통계 카드 래퍼 컴포넌트
-// ═══════════════════════════════════════════════════════════════
-async function BiddingsStatsCardsWrapper({
- promises
-}: {
- promises: Promise<[
- Awaited<ReturnType<typeof getBiddings>>,
- Awaited<ReturnType<typeof getBiddingStatusCounts>>,
- Awaited<ReturnType<typeof getBiddingTypeCounts>>,
- Awaited<ReturnType<typeof getBiddingManagerCounts>>,
- Awaited<ReturnType<typeof getBiddingMonthlyStats>>,
- ]>
-}) {
- const [biddingsResult, statusCounts, typeCounts, managerCounts, monthlyStats] = await promises
-
- return (
- <BiddingsStatsCards
- total={biddingsResult.total}
- statusCounts={statusCounts}
- typeCounts={typeCounts}
- managerCounts={managerCounts}
- monthlyStats={monthlyStats}
- />
- )
-}
-
-// 통계 카드 스켈레톤
-function BiddingsStatsCardsSkeleton() {
- return (
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
- {Array.from({ length: 4 }).map((_, i) => (
- <div key={i} className="rounded-lg border p-6">
- <div className="h-4 bg-muted rounded animate-pulse mb-2" />
- <div className="h-8 bg-muted rounded animate-pulse" />
- </div>
- ))}
- </div>
- )
-} \ No newline at end of file
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/bidding-notice/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/bidding-notice/page.tsx
index 003db012..09ce13e7 100644
--- a/app/[lng]/evcp/(evcp)/(procurement)/bidding-notice/page.tsx
+++ b/app/[lng]/evcp/(evcp)/(procurement)/bidding-notice/page.tsx
@@ -1,38 +1,24 @@
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { BiddingNoticeEditor } from '@/lib/bidding/bidding-notice-editor'
-import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
+import { BiddingNoticeTemplateManager } from '@/lib/bidding/bidding-notice-template-manager'
+import { getBiddingNoticeTemplates } from '@/lib/bidding/service'
// template 받을 때, 비동기 함수 호출 후 await 하므로 static-pre-render 과정에서 dynamic-server-error 발생.
// 따라서, dynamic 속성을 force-dynamic 으로 설정하여 동적 렌더링 처리
-// getBiddingNoticeTemplate 함수에 대한 Promise를 넘기는 식으로 수정하게 되면 force-dynamic 선언을 제거해도 됨.
export const dynamic = 'force-dynamic'
export default async function BiddingNoticePage() {
- const template = await getBiddingNoticeTemplate()
+ const templates = await getBiddingNoticeTemplates()
return (
<div className="container mx-auto py-6 max-w-6xl">
<div className="mb-6">
- <h1 className="text-3xl font-bold tracking-tight">입찰공고문 관리</h1>
+ <h1 className="text-3xl font-bold tracking-tight">입찰공고문 템플릿 관리</h1>
<p className="text-muted-foreground mt-2">
- 표준 입찰공고문 템플릿을 작성하고 관리할 수 있습니다.
+ 입찰공고문 템플릿을 타입별로 작성하고 관리할 수 있습니다.
+ 각 타입별 템플릿은 입찰 생성 시 기본 양식으로 사용됩니다.
</p>
</div>
- <Card>
- <CardHeader>
- <CardTitle>표준 입찰공고문 템플릿</CardTitle>
- <CardDescription>
- 이 템플릿은 실제 입찰 공고 작성 시 기본 양식으로 사용됩니다.
- 필요한 표준 정보와 서식을 미리 작성해두세요.
- </CardDescription>
- </CardHeader>
- <CardContent>
- <BiddingNoticeEditor
- initialData={template}
- />
- </CardContent>
- </Card>
+ <BiddingNoticeTemplateManager initialTemplates={templates} />
</div>
)
} \ No newline at end of file
diff --git a/app/[lng]/partners/(partners)/bid/[id]/page.tsx b/app/[lng]/partners/(partners)/bid/[id]/page.tsx
index b8c7ea59..b564b48f 100644
--- a/app/[lng]/partners/(partners)/bid/[id]/page.tsx
+++ b/app/[lng]/partners/(partners)/bid/[id]/page.tsx
@@ -38,6 +38,8 @@ export default async function PartnersBidDetailPage(props: PartnersBidDetailPage
</div>
)
}
+ console.log('biddingId:', biddingId)
+ console.log('companyId:', companyId)
return (
<div className="container mx-auto py-6">
diff --git a/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx b/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx
deleted file mode 100644
index 6364f7f8..00000000
--- a/app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-import { PartnersBiddingPreQuote } from '@/lib/bidding/vendor/partners-bidding-pre-quote'
-import { Suspense } from 'react'
-import { Skeleton } from '@/components/ui/skeleton'
-
-import { getServerSession } from 'next-auth'
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-
-interface PartnersPreQuotePageProps {
- params: Promise<{
- id: string
- }>
-}
-
-export default async function PartnersPreQuotePage(props: PartnersPreQuotePageProps) {
- const resolvedParams = await props.params
- const biddingId = parseInt(resolvedParams.id)
-
- if (isNaN(biddingId)) {
- return (
- <div className="container mx-auto py-6">
- <div className="text-center">
- <h1 className="text-2xl font-bold text-destructive">유효하지 않은 입찰 ID입니다.</h1>
- </div>
- </div>
- )
- }
-
- // 세션에서 companyId 가져오기
- const session = await getServerSession(authOptions)
- const companyId = session?.user?.companyId
-
- if (!companyId) {
- return (
- <div className="container mx-auto py-6">
- <div className="text-center">
- <h1 className="text-2xl font-bold text-destructive">회사 정보가 없습니다. 다시 로그인 해주세요.</h1>
- </div>
- </div>
- )
- }
-
- return (
- <div className="container mx-auto py-6">
- <Suspense fallback={<PreQuoteSkeleton />}>
- <PartnersBiddingPreQuote
- biddingId={biddingId}
- companyId={companyId}
- />
- </Suspense>
- </div>
- )
-}
-
-function PreQuoteSkeleton() {
- return (
- <div className="space-y-6">
- {/* 헤더 스켈레톤 */}
- <div className="flex items-center justify-between">
- <div className="space-y-2">
- <Skeleton className="h-8 w-64" />
- <Skeleton className="h-4 w-48" />
- </div>
- </div>
-
- {/* 입찰 공고 스켈레톤 */}
- <div className="space-y-4">
- <Skeleton className="h-8 w-32" />
- <div className="space-y-2">
- {Array.from({ length: 6 }).map((_, i) => (
- <Skeleton key={i} className="h-6 w-full" />
- ))}
- </div>
- </div>
-
- {/* 현재 설정된 조건 스켈레톤 */}
- <div className="space-y-4">
- <Skeleton className="h-8 w-32" />
- <div className="grid grid-cols-2 gap-4">
- {Array.from({ length: 8 }).map((_, i) => (
- <Skeleton key={i} className="h-16 w-full" />
- ))}
- </div>
- </div>
-
- {/* 사전견적 폼 스켈레톤 */}
- <div className="space-y-4">
- <Skeleton className="h-8 w-32" />
- <div className="space-y-4">
- {Array.from({ length: 10 }).map((_, i) => (
- <Skeleton key={i} className="h-10 w-full" />
- ))}
- <Skeleton className="h-12 w-32" />
- </div>
- </div>
- </div>
- )
-}
diff --git a/app/[lng]/partners/(partners)/bid/page.tsx b/app/[lng]/partners/(partners)/bid/page.tsx
index 05081c3a..a09dec72 100644
--- a/app/[lng]/partners/(partners)/bid/page.tsx
+++ b/app/[lng]/partners/(partners)/bid/page.tsx
@@ -5,7 +5,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { getBiddingListForPartners } from '@/lib/bidding/detail/service'
import { Shell } from '@/components/shell'
import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton'
-import { InformationButton } from '@/components/information/information-button'
+
export default async function PartnersBidPage() {
// 세션에서 companyId 가져오기
const session = await getServerSession(authOptions)
@@ -31,10 +31,7 @@ export default async function PartnersBidPage() {
<div className="container mx-auto py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
- <div className="flex items-center gap-2">
- <h1 className="text-3xl font-bold">입찰 참여</h1>
- <InformationButton pagePath="partners/bid" />
- </div>
+ <h1 className="text-3xl font-bold">입찰 참여</h1>
<p className="text-muted-foreground mt-2">
참여 가능한 입찰 목록을 확인하고 응찰하실 수 있습니다.
</p>
diff --git a/app/[lng]/partners/(partners)/general-contract-review/[contractId]/page.tsx b/app/[lng]/partners/(partners)/general-contract-review/[contractId]/page.tsx
new file mode 100644
index 00000000..82ff41c3
--- /dev/null
+++ b/app/[lng]/partners/(partners)/general-contract-review/[contractId]/page.tsx
@@ -0,0 +1,62 @@
+import * as React from "react"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { redirect } from "next/navigation"
+import { Shell } from "@/components/shell"
+import { getContractForVendorReview } from "@/lib/general-contracts/service"
+import { VendorContractReviewClient, type VendorContractReviewClientProps } from "./vendor-contract-review-client"
+
+interface VendorContractReviewPageProps {
+ params: Promise<{ contractId: string }>
+}
+
+export default async function VendorContractReviewPage(props: VendorContractReviewPageProps) {
+ const resolvedParams = await props.params
+ const contractId = parseInt(resolvedParams.contractId)
+
+ if (isNaN(contractId)) {
+ redirect('/partners')
+ }
+
+ // 세션에서 벤더 정보 가져오기
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.companyId) {
+ return (
+ <div className="flex h-full items-center justify-center p-6">
+ 정상적인 벤더에 소속된 계정이 아닙니다.
+ </div>
+ )
+ }
+
+ const vendorId = session.user.companyId
+
+ try {
+ // 협력업체용 계약 정보 조회
+ const contract = await getContractForVendorReview(contractId, vendorId)
+
+ return (
+ <Shell className="gap-2">
+ <VendorContractReviewClient
+ contract={{
+ ...contract,
+ name: contract.name || '',
+ } as VendorContractReviewClientProps['contract']}
+ vendorId={vendorId}
+ />
+ </Shell>
+ )
+ } catch (error) {
+ console.error('계약 정보 조회 오류:', error)
+ return (
+ <div className="flex h-full items-center justify-center p-6">
+ <div className="text-center space-y-2">
+ <p className="text-destructive">계약 정보를 불러올 수 없습니다.</p>
+ <p className="text-muted-foreground text-sm">
+ {error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'}
+ </p>
+ </div>
+ </div>
+ )
+ }
+}
+
diff --git a/app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx b/app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx
new file mode 100644
index 00000000..ee5815d6
--- /dev/null
+++ b/app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx
@@ -0,0 +1,423 @@
+'use client'
+
+import React, { useState, useEffect, useRef } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Badge } from '@/components/ui/badge'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import {
+ FileText,
+ Send,
+ Save,
+ LoaderIcon,
+ AlertCircle,
+ Eye,
+ ArrowLeft,
+ MessageSquare
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { useRouter } from 'next/navigation'
+import {
+ getContractForVendorReview,
+ vendorReplyToContractReview,
+ saveVendorCommentDraft
+} from '@/lib/general-contracts/service'
+import type { WebViewerInstance } from '@pdftron/webviewer'
+
+interface VendorContractReviewClientProps {
+ contract: {
+ id: number
+ contractNumber: string
+ revision: number
+ name: string | null
+ status: string
+ type: string | null
+ category: string | null
+ vendorId: number
+ contractAmount: number | null
+ currency: string | null
+ startDate: string | null
+ endDate: string | null
+ specificationType: string | null
+ specificationManualText: string | null
+ contractScope: string | null
+ notes: string | null
+ contractItems: Array<Record<string, unknown>>
+ attachments: Array<{
+ id: number
+ contractId: number
+ documentName: string
+ fileName: string
+ filePath: string
+ vendorComment: string | null
+ shiComment: string | null
+ uploadedAt: Date
+ uploadedById: number
+ }>
+ vendor: {
+ id: number
+ vendorCode: string | null
+ vendorName: string | null
+ } | null
+ }
+ vendorId: number
+}
+
+export function VendorContractReviewClient({
+ contract: initialContract,
+ vendorId
+}: VendorContractReviewClientProps) {
+ const router = useRouter()
+ const [contract, setContract] = useState<VendorContractReviewClientProps['contract']>(initialContract)
+ const [vendorComment, setVendorComment] = useState<string>('')
+ const [isSaving, setIsSaving] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ // PDFTron Viewer 관련 상태
+ const viewerRef = useRef<HTMLDivElement>(null)
+ const instanceRef = useRef<WebViewerInstance | null>(null)
+ const [viewerLoading, setViewerLoading] = useState(false)
+ const [viewerInitialized, setViewerInitialized] = useState(false)
+
+ // 계약 첨부파일에서 기존 Vendor Comment 로드
+ useEffect(() => {
+ if (contract?.attachments && contract.attachments.length > 0) {
+ const firstAttachment = contract.attachments[0]
+ if (firstAttachment.vendorComment) {
+ setVendorComment(firstAttachment.vendorComment)
+ }
+ }
+ }, [contract])
+
+ // PDFTron Viewer 초기화
+ useEffect(() => {
+ if (!viewerRef.current || viewerInitialized) return
+
+ const initializeViewer = async () => {
+ try {
+ setViewerLoading(true)
+ const { default: WebViewer } = await import('@pdftron/webviewer')
+
+ if (!viewerRef.current) return
+
+ const instance = await WebViewer(
+ {
+ path: '/pdftronWeb',
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY || '',
+ fullAPI: true,
+ enableFilePicker: false,
+ enableMeasurement: false,
+ enableRedaction: false,
+ enableAnnotations: false,
+ enablePrint: false,
+ enableDownload: false,
+ },
+ viewerRef.current
+ )
+
+ instanceRef.current = instance
+ setViewerInitialized(true)
+ setViewerLoading(false)
+
+ // 계약서 초안 PDF가 있으면 로드
+ if (contract?.attachments && contract.attachments.length > 0) {
+ const pdfAttachment = contract.attachments.find(
+ (att) => att.filePath && att.filePath.endsWith('.pdf')
+ )
+ if (pdfAttachment && pdfAttachment.filePath) {
+ // 파일 경로를 완전한 URL로 변환
+ const fileUrl = pdfAttachment.filePath.startsWith('http')
+ ? pdfAttachment.filePath
+ : `${process.env.NEXT_PUBLIC_URL}${pdfAttachment.filePath}`
+
+ instance.UI.loadDocument(fileUrl)
+ }
+ }
+ } catch (error) {
+ console.error('PDFTron Viewer 초기화 오류:', error)
+ setViewerLoading(false)
+ toast.error('문서 뷰어를 초기화할 수 없습니다.')
+ }
+ }
+
+ initializeViewer()
+
+ return () => {
+ if (instanceRef.current) {
+ try {
+ instanceRef.current.UI.dispose()
+ } catch (error) {
+ console.error('Viewer 정리 오류:', error)
+ }
+ }
+ }
+ }, [contract, viewerInitialized])
+
+ // 임시 저장
+ const handleSaveDraft = async () => {
+ if (!vendorComment.trim()) {
+ toast.error('의견을 입력해주세요.')
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ await saveVendorCommentDraft(contract.id, vendorComment, vendorId)
+ toast.success('의견이 임시 저장되었습니다.')
+ } catch (error) {
+ console.error('임시 저장 오류:', error)
+ const errorMessage = error instanceof Error ? error.message : '임시 저장에 실패했습니다.'
+ toast.error(errorMessage)
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // 의견 회신
+ const handleSubmitReply = async () => {
+ if (!vendorComment.trim()) {
+ toast.error('의견을 입력해주세요.')
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ await vendorReplyToContractReview(contract.id, vendorComment, vendorId)
+ toast.success('의견이 성공적으로 회신되었습니다.')
+
+ // 계약 정보 다시 로드
+ const updatedContract = await getContractForVendorReview(contract.id, vendorId)
+ setContract({
+ ...updatedContract,
+ name: updatedContract.name || '',
+ } as VendorContractReviewClientProps['contract'])
+
+ // 상태 변경 후 메시지 표시
+ setTimeout(() => {
+ router.push('/partners/dashboard')
+ }, 2000)
+ } catch (error) {
+ console.error('의견 회신 오류:', error)
+ const errorMessage = error instanceof Error ? error.message : '의견 회신에 실패했습니다.'
+ toast.error(errorMessage)
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const getStatusLabel = (status: string) => {
+ const statusLabels: Record<string, string> = {
+ 'Draft': '임시저장',
+ 'Request to Review': '조건검토요청',
+ 'Vendor Replied Review': '협력업체 회신',
+ 'SHI Confirmed Review': '당사 검토 확정',
+ 'Contract Accept Request': '계약승인요청',
+ 'Complete the Contract': '계약체결',
+ }
+ return statusLabels[status] || status
+ }
+
+ const getStatusColor = (status: string) => {
+ const statusColors: Record<string, string> = {
+ 'Request to Review': 'bg-yellow-100 text-yellow-800',
+ 'Vendor Replied Review': 'bg-blue-100 text-blue-800',
+ 'SHI Confirmed Review': 'bg-green-100 text-green-800',
+ }
+ return statusColors[status] || 'bg-gray-100 text-gray-800'
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="flex items-center gap-2 mb-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => router.push('/partners/dashboard')}
+ >
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ 돌아가기
+ </Button>
+ </div>
+ <h1 className="text-3xl font-bold tracking-tight">일반계약 조건검토</h1>
+ <p className="text-muted-foreground">
+ 계약번호: {contract.contractNumber} (Rev.{contract.revision})
+ </p>
+ </div>
+ <div className="flex items-center gap-2">
+ <Badge className={getStatusColor(contract.status)}>
+ {getStatusLabel(contract.status)}
+ </Badge>
+ </div>
+ </div>
+
+ {/* 상태 안내 */}
+ {contract.status === 'Request to Review' && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 계약 조건 검토를 요청받았습니다. 계약서 초안을 확인하고 의견을 입력해주세요.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 계약 정보 카드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 계약 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label className="text-sm text-muted-foreground">계약명</Label>
+ <p className="font-medium">{contract.name || '-'}</p>
+ </div>
+ <div>
+ <Label className="text-sm text-muted-foreground">계약금액</Label>
+ <p className="font-medium">
+ {contract.contractAmount?.toLocaleString() || '0'} {contract.currency || 'KRW'}
+ </p>
+ </div>
+ <div>
+ <Label className="text-sm text-muted-foreground">계약기간</Label>
+ <p className="font-medium">
+ {contract.startDate ? new Date(contract.startDate).toLocaleDateString() : '-'} ~{' '}
+ {contract.endDate ? new Date(contract.endDate).toLocaleDateString() : '-'}
+ </p>
+ </div>
+ <div>
+ <Label className="text-sm text-muted-foreground">계약확정범위</Label>
+ <p className="font-medium">{contract.contractScope || '-'}</p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 계약서 초안 뷰어 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5" />
+ 계약서 초안
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="relative" style={{ height: '600px' }}>
+ {viewerLoading && (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <LoaderIcon className="h-8 w-8 animate-spin" />
+ <span className="ml-2">문서를 불러오는 중...</span>
+ </div>
+ )}
+ <div ref={viewerRef} className="w-full h-full" />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* Vendor Comment 입력 */}
+ {contract.status === 'Request to Review' && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 검토 의견 입력
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="vendor-comment" className="text-sm font-medium">
+ Vendor Comment <span className="text-red-500">*</span>
+ </Label>
+ <Textarea
+ id="vendor-comment"
+ value={vendorComment}
+ onChange={(e) => setVendorComment(e.target.value)}
+ placeholder="계약 조건에 대한 검토 의견을 입력해주세요."
+ rows={8}
+ className="resize-none bg-yellow-50 border-2 border-yellow-200"
+ required
+ />
+ <p className="text-xs text-muted-foreground">
+ 노란색 배경 필드는 필수 입력 항목입니다.
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ onClick={handleSaveDraft}
+ disabled={isSaving || isSubmitting}
+ variant="outline"
+ >
+ {isSaving ? (
+ <>
+ <LoaderIcon className="h-4 w-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="h-4 w-4 mr-2" />
+ 임시 저장
+ </>
+ )}
+ </Button>
+ <Button
+ onClick={handleSubmitReply}
+ disabled={isSaving || isSubmitting || !vendorComment.trim()}
+ className="flex-1"
+ >
+ {isSubmitting ? (
+ <>
+ <LoaderIcon className="h-4 w-4 mr-2 animate-spin" />
+ 전송 중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ 의견 회신
+ </>
+ )}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 이미 회신한 경우 */}
+ {/* {contract.status === 'Vendor Replied Review' && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 검토 의견
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2">
+ <Label className="text-sm font-medium">Vendor Comment</Label>
+ <div className="min-h-[120px] p-4 bg-yellow-50 border-2 border-yellow-200 rounded-lg">
+ {vendorComment ? (
+ <p className="text-sm whitespace-pre-wrap">{vendorComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">의견이 없습니다.</p>
+ )}
+ </div>
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ 의견이 회신되었습니다. 당사 검토 후 연락드리겠습니다.
+ </AlertDescription>
+ </Alert>
+ </div>
+ </CardContent>
+ </Card>
+ )} */}
+ </div>
+ )
+}
+
diff --git a/app/[lng]/partners/(partners)/general-contract-review/page.tsx b/app/[lng]/partners/(partners)/general-contract-review/page.tsx
new file mode 100644
index 00000000..27afd859
--- /dev/null
+++ b/app/[lng]/partners/(partners)/general-contract-review/page.tsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { Shell } from "@/components/shell"
+import { getVendorContractReviews } from "@/lib/general-contracts/service"
+import { VendorGeneralContractReviewTable } from "./vendor-general-contract-review-table"
+import { InformationButton } from "@/components/information/information-button"
+import { unstable_noStore as noStore } from 'next/cache'
+
+export default async function VendorGeneralContractReviewPage() {
+ noStore()
+
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user?.companyId) {
+ return (
+ <div className="flex h-full items-center justify-center p-6">
+ 정상적인 벤더에 소속된 계정이 아닙니다.
+ </div>
+ )
+ }
+
+ const vendorId = session.user.companyId
+
+ // 데이터 가져오기
+ const contractReviews = await getVendorContractReviews(vendorId, 1, 100, '')
+
+ return (
+ <Shell className="gap-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ 일반계약 조건검토
+ </h2>
+ <InformationButton pagePath="partners/general-contract-review" />
+ </div>
+ <p className="text-muted-foreground">
+ 조건검토 요청된 계약 목록을 확인하고 검토합니다.
+ </p>
+ </div>
+ </div>
+
+ <VendorGeneralContractReviewTable data={contractReviews.data} />
+ </Shell>
+ )
+}
+
diff --git a/app/[lng]/partners/(partners)/general-contract-review/vendor-general-contract-review-table.tsx b/app/[lng]/partners/(partners)/general-contract-review/vendor-general-contract-review-table.tsx
new file mode 100644
index 00000000..da34708c
--- /dev/null
+++ b/app/[lng]/partners/(partners)/general-contract-review/vendor-general-contract-review-table.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Eye } from "lucide-react"
+import { GeneralContractListItem } from "@/lib/general-contracts/main/general-contracts-table-columns"
+
+interface VendorGeneralContractReviewTableProps {
+ data: GeneralContractListItem[]
+}
+
+function getStatusBadge(status: string) {
+ const statusLabels: Record<string, string> = {
+ 'Draft': '임시저장',
+ 'Request to Review': '조건검토요청',
+ 'Vendor Replied Review': '협력업체 회신',
+ 'SHI Confirmed Review': '당사 검토 확정',
+ 'Contract Accept Request': '계약승인요청',
+ 'Complete the Contract': '계약체결',
+ 'Reject to Accept Contract': '계약승인거절',
+ 'Contract Delete': '계약폐기',
+ }
+
+ const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
+ 'Request to Review': 'secondary',
+ 'Vendor Replied Review': 'default',
+ 'SHI Confirmed Review': 'default',
+ }
+
+ const label = statusLabels[status] || status
+ const variant = statusColors[status] || 'outline'
+
+ return <Badge variant={variant}>{label}</Badge>
+}
+
+function getFormattedDate(dateString: string | null | undefined) {
+ if (!dateString) return "-"
+ try {
+ return new Intl.DateTimeFormat("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(dateString))
+ } catch {
+ return "-"
+ }
+}
+
+function getFormattedDateRange(startDate: string | null | undefined, endDate: string | null | undefined) {
+ if (!startDate && !endDate) return "-"
+ const start = startDate ? getFormattedDate(startDate) : "-"
+ const end = endDate ? getFormattedDate(endDate) : "-"
+ return `${start} ~ ${end}`
+}
+
+export function VendorGeneralContractReviewTable({ data }: VendorGeneralContractReviewTableProps) {
+ const router = useRouter()
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>조건검토 계약 목록</CardTitle>
+ </CardHeader>
+ <CardContent className="p-0">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>계약번호</TableHead>
+ <TableHead>계약명</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead>계약기간</TableHead>
+ <TableHead>등록일</TableHead>
+ <TableHead>작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
+ 조건검토 요청된 계약이 없습니다.
+ </TableCell>
+ </TableRow>
+ ) : (
+ data.map((contract) => (
+ <TableRow key={contract.id}>
+ <TableCell>
+ <div className="font-medium">
+ {contract.contractNumber}
+ {contract.revision > 0 && (
+ <span className="text-muted-foreground ml-1">
+ (Rev.{contract.revision})
+ </span>
+ )}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="max-w-[300px] truncate">
+ {contract.name || "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ {getStatusBadge(contract.status)}
+ </TableCell>
+ <TableCell>
+ {getFormattedDateRange(contract.startDate, contract.endDate)}
+ </TableCell>
+ <TableCell>
+ {getFormattedDate(contract.registeredAt)}
+ </TableCell>
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ router.push(`/partners/general-contract-review/${contract.id}`)
+ }}
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ 조회
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))
+ )}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/components/bidding/bidding-conditions-edit.tsx b/components/bidding/bidding-conditions-edit.tsx
deleted file mode 100644
index 1017597b..00000000
--- a/components/bidding/bidding-conditions-edit.tsx
+++ /dev/null
@@ -1,469 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation"
-import { useTransition } from "react"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Label } from "@/components/ui/label"
-import { Switch } from "@/components/ui/switch"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Pencil, Save, X } from "lucide-react"
-import { getBiddingConditions, updateBiddingConditions } from "@/lib/bidding/service"
-import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service"
-import { TAX_CONDITIONS, getTaxConditionName } from "@/lib/tax-conditions/types"
-import { useToast } from "@/hooks/use-toast"
-
-interface BiddingConditionsEditProps {
- biddingId: number
- initialConditions?: any | null
-}
-
-export function BiddingConditionsEdit({ biddingId, initialConditions }: BiddingConditionsEditProps) {
- const router = useRouter()
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [isEditing, setIsEditing] = React.useState(false)
-
- // Procurement 데이터 상태들
- const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [shippingPlaces, setShippingPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [procurementLoading, setProcurementLoading] = React.useState(false)
-
- const [conditions, setConditions] = React.useState({
- paymentTerms: initialConditions?.paymentTerms || "",
- taxConditions: initialConditions?.taxConditions || "",
- incoterms: initialConditions?.incoterms || "",
- contractDeliveryDate: initialConditions?.contractDeliveryDate
- ? new Date(initialConditions.contractDeliveryDate).toISOString().split('T')[0]
- : "",
- shippingPort: initialConditions?.shippingPort || "",
- destinationPort: initialConditions?.destinationPort || "",
- isPriceAdjustmentApplicable: initialConditions?.isPriceAdjustmentApplicable || false,
- sparePartOptions: initialConditions?.sparePartOptions || "",
- })
-
-
- const handleSave = () => {
- startTransition(async () => {
- try {
- const result = await updateBiddingConditions(biddingId, conditions)
-
- if (result.success) {
- toast({
- title: "성공",
- description: (result as { success: true; message: string }).message,
- variant: "default",
- })
- setIsEditing(false)
- router.refresh()
- } else {
- toast({
- title: "오류",
- description: (result as { success: false; error: string }).error || "입찰 조건 업데이트 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- } catch (error) {
- console.error('Error updating bidding conditions:', error)
- toast({
- title: "오류",
- description: "입찰 조건 업데이트 중 오류가 발생했습니다.",
- variant: "destructive",
- })
- }
- })
- }
-
- // Procurement 데이터 로드 함수들
- const loadPaymentTerms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPaymentTermsForSelection();
- setPaymentTermsOptions(data);
- } catch (error) {
- console.error("Failed to load payment terms:", error);
- toast({
- title: "오류",
- description: "결제조건 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- })
- } finally {
- setProcurementLoading(false);
- }
- }, [toast]);
-
- const loadIncoterms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getIncotermsForSelection();
- setIncotermsOptions(data);
- } catch (error) {
- console.error("Failed to load incoterms:", error);
- toast({
- title: "오류",
- description: "운송조건 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- })
- } finally {
- setProcurementLoading(false);
- }
- }, [toast]);
-
- const loadShippingPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfShippingForSelection();
- setShippingPlaces(data);
- } catch (error) {
- console.error("Failed to load shipping places:", error);
- toast({
- title: "오류",
- description: "선적지 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- })
- } finally {
- setProcurementLoading(false);
- }
- }, [toast]);
-
- const loadDestinationPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfDestinationForSelection();
- setDestinationPlaces(data);
- } catch (error) {
- console.error("Failed to load destination places:", error);
- toast({
- title: "오류",
- description: "하역지 목록을 불러오는데 실패했습니다.",
- variant: "destructive",
- })
- } finally {
- setProcurementLoading(false);
- }
- }, [toast]);
-
- // 편집 모드로 전환할 때 procurement 데이터 로드
- React.useEffect(() => {
- if (isEditing) {
- loadPaymentTerms();
- loadIncoterms();
- loadShippingPlaces();
- loadDestinationPlaces();
- }
- }, [isEditing, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]);
-
- const handleCancel = () => {
- setConditions({
- paymentTerms: initialConditions?.paymentTerms || "",
- taxConditions: initialConditions?.taxConditions || "",
- incoterms: initialConditions?.incoterms || "",
- contractDeliveryDate: initialConditions?.contractDeliveryDate
- ? new Date(initialConditions.contractDeliveryDate).toISOString().split('T')[0]
- : "",
- shippingPort: initialConditions?.shippingPort || "",
- destinationPort: initialConditions?.destinationPort || "",
- isPriceAdjustmentApplicable: initialConditions?.isPriceAdjustmentApplicable || false,
- sparePartOptions: initialConditions?.sparePartOptions || "",
- })
- setIsEditing(false)
- }
-
- if (!isEditing) {
- return (
- <Card className="mt-6">
- <CardHeader className="flex flex-row items-center justify-between">
- <CardTitle>입찰 조건</CardTitle>
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsEditing(true)}
- className="flex items-center gap-2"
- >
- <Pencil className="w-4 h-4" />
- 수정
- </Button>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
- <div>
- <Label className="text-muted-foreground">지급조건</Label>
- <p className="font-medium">
- {conditions.paymentTerms
- ? paymentTermsOptions.find(opt => opt.code === conditions.paymentTerms)?.code || conditions.paymentTerms
- : "미설정"
- }
- </p>
- </div>
- <div>
- <Label className="text-muted-foreground">세금조건</Label>
- <p className="font-medium">
- {conditions.taxConditions
- ? getTaxConditionName(conditions.taxConditions)
- : "미설정"
- }
- </p>
- </div>
- <div>
- <Label className="text-muted-foreground">운송조건</Label>
- <p className="font-medium">
- {conditions.incoterms
- ? incotermsOptions.find(opt => opt.code === conditions.incoterms)?.code || conditions.incoterms
- : "미설정"
- }
- </p>
- </div>
- <div>
- <Label className="text-muted-foreground">계약 납품일</Label>
- <p className="font-medium">
- {conditions.contractDeliveryDate
- ? new Date(conditions.contractDeliveryDate).toLocaleDateString('ko-KR')
- : "미설정"
- }
- </p>
- </div>
- <div>
- <Label className="text-muted-foreground">선적지</Label>
- <p className="font-medium">{conditions.shippingPort || "미설정"}</p>
- </div>
- <div>
- <Label className="text-muted-foreground">하역지</Label>
- <p className="font-medium">{conditions.destinationPort || "미설정"}</p>
- </div>
- <div>
- <Label className="text-muted-foreground">연동제 적용</Label>
- <p className="font-medium">{conditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
- </div>
- <div>
- <Label className="text-muted-foreground">스페어파트 옵션</Label>
- <p className="font-medium">{conditions.sparePartOptions}</p>
- </div>
-
- </div>
- </CardContent>
- </Card>
- )
- }
-
- return (
- <Card className="mt-6">
- <CardHeader className="flex flex-row items-center justify-between">
- <CardTitle>입찰 조건 수정</CardTitle>
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleCancel}
- disabled={isPending}
- className="flex items-center gap-2"
- >
- <X className="w-4 h-4" />
- 취소
- </Button>
- <Button
- size="sm"
- onClick={handleSave}
- disabled={isPending}
- className="flex items-center gap-2"
- >
- <Save className="w-4 h-4" />
- 저장
- </Button>
- </div>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="paymentTerms">지급조건 *</Label>
- <Select
- value={conditions.paymentTerms}
- onValueChange={(value) => setConditions(prev => ({
- ...prev,
- paymentTerms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="지급조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="taxConditions">세금조건 *</Label>
- <Select
- value={conditions.taxConditions}
- onValueChange={(value) => setConditions(prev => ({
- ...prev,
- taxConditions: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="세금조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.length > 0 ? (
- TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="incoterms">운송조건(인코텀즈) *</Label>
- <Select
- value={conditions.incoterms}
- onValueChange={(value) => setConditions(prev => ({
- ...prev,
- incoterms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="contractDeliveryDate">계약 납품일</Label>
- <Input
- id="contractDeliveryDate"
- type="date"
- value={conditions.contractDeliveryDate}
- onChange={(e) => setConditions(prev => ({
- ...prev,
- contractDeliveryDate: e.target.value
- }))}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="shippingPort">선적지</Label>
- <Select
- value={conditions.shippingPort}
- onValueChange={(value) => setConditions(prev => ({
- ...prev,
- shippingPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="선적지 선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="destinationPort">하역지</Label>
- <Select
- value={conditions.destinationPort}
- onValueChange={(value) => setConditions(prev => ({
- ...prev,
- destinationPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="하역지 선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <div className="flex items-center space-x-2">
- <Switch
- id="isPriceAdjustmentApplicable"
- checked={conditions.isPriceAdjustmentApplicable}
- onCheckedChange={(checked) => setConditions(prev => ({
- ...prev,
- isPriceAdjustmentApplicable: checked
- }))}
- />
- <Label htmlFor="isPriceAdjustmentApplicable">연동제 적용 가능</Label>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="sparePartOptions">스페어파트 옵션</Label>
- <Textarea
- id="sparePartOptions"
- placeholder="스페어파트 관련 옵션을 입력하세요"
- value={conditions.sparePartOptions}
- onChange={(e) => setConditions(prev => ({
- ...prev,
- sparePartOptions: e.target.value
- }))}
- rows={3}
- />
- </div>
- </CardContent>
- </Card>
- )
-}
diff --git a/components/bidding/bidding-info-header.tsx b/components/bidding/bidding-info-header.tsx
index b897187d..0b2d2b47 100644
--- a/components/bidding/bidding-info-header.tsx
+++ b/components/bidding/bidding-info-header.tsx
@@ -1,6 +1,6 @@
import { Bidding } from '@/db/schema/bidding'
-import { Building2, Package, User, DollarSign, Calendar } from 'lucide-react'
-import { contractTypeLabels, biddingTypeLabels } from '@/db/schema/bidding'
+import { Building2, User, DollarSign, Calendar, FileText } from 'lucide-react'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema/bidding'
import { formatDate } from '@/lib/utils'
interface BiddingInfoHeaderProps {
@@ -18,122 +18,175 @@ export function BiddingInfoHeader({ bidding }: BiddingInfoHeaderProps) {
return (
<div className="bg-white border rounded-lg p-6 mb-6 shadow-sm">
- {/* 3개 섹션을 Grid로 배치 - 각 섹션이 동일한 width로 꽉 채움 */}
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
- {/* 왼쪽 섹션: 프로젝트, 품목, 담당자 정보 */}
+ {/* 4개 섹션을 Grid로 배치 */}
+ <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
+ {/* 1. 프로젝트 및 품목 정보 */}
<div className="w-full space-y-4">
- {/* 프로젝트 정보 */}
+ <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
+ <Building2 className="w-4 h-4" />
+ <span>기본 정보</span>
+ </div>
+
{bidding.projectName && (
- <div className="mb-4">
- <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
- <Building2 className="w-4 h-4" />
- <span>프로젝트</span>
- </div>
- <div className="font-medium text-gray-900">{bidding.projectName}</div>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">프로젝트</div>
+ <div className="font-medium text-gray-900 text-sm">{bidding.projectName}</div>
</div>
)}
- {/* 품목 정보 */}
{bidding.itemName && (
- <div className="mb-4">
- <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
- <Package className="w-4 h-4" />
- <span>품목</span>
- </div>
- <div className="font-medium text-gray-900">{bidding.itemName}</div>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">품목</div>
+ <div className="font-medium text-gray-900 text-sm">{bidding.itemName}</div>
+ </div>
+ )}
+
+ {bidding.prNumber && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">PR No.</div>
+ <div className="font-mono text-sm font-medium text-gray-900">{bidding.prNumber}</div>
+ </div>
+ )}
+
+ {bidding.purchasingOrganization && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">구매조직</div>
+ <div className="font-medium text-gray-900 text-sm">{bidding.purchasingOrganization}</div>
</div>
)}
+ </div>
- {/* 담당자 정보 */}
- {bidding.managerName && (
- <div className="mb-4">
- <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
- <User className="w-4 h-4" />
- <span>담당자</span>
+ {/* 2. 담당자 및 예산 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6 space-y-4">
+ <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
+ <User className="w-4 h-4" />
+ <span>담당자 정보</span>
+ </div>
+
+ {bidding.bidPicName && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">입찰담당자</div>
+ <div className="font-medium text-gray-900 text-sm">
+ {bidding.bidPicName}
+ {bidding.bidPicCode && (
+ <span className="ml-2 text-xs text-gray-500">({bidding.bidPicCode})</span>
+ )}
</div>
- <div className="font-medium text-gray-900">{bidding.managerName}</div>
</div>
)}
- {/* 예산 정보 */}
+ {bidding.supplyPicName && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">조달담당자</div>
+ <div className="font-medium text-gray-900 text-sm">
+ {bidding.supplyPicName}
+ {bidding.supplyPicCode && (
+ <span className="ml-2 text-xs text-gray-500">({bidding.supplyPicCode})</span>
+ )}
+ </div>
+ </div>
+ )}
+
{bidding.budget && (
- <div className="mb-4">
- <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
- <DollarSign className="w-4 h-4" />
+ <div>
+ <div className="flex items-center gap-1.5 text-xs text-gray-500 mb-1">
+ <DollarSign className="w-3 h-3" />
<span>예산</span>
</div>
- <div className="font-semibold text-gray-900">
+ <div className="font-semibold text-gray-900 text-sm">
{new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: bidding.currency || 'KRW',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
}).format(Number(bidding.budget))}
</div>
</div>
)}
</div>
- {/* 가운데 섹션: 계약 정보 */}
- <div className="w-full border-l border-gray-100 pl-6">
- <div className="grid grid-cols-2 gap-4">
- <div className="flex flex-col gap-1">
- <span className="text-gray-500 text-sm">계약</span>
- <span className="font-medium">{contractTypeLabels[bidding.contractType]}</span>
+ {/* 3. 계약 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6 space-y-4">
+ <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
+ <FileText className="w-4 h-4" />
+ <span>계약 정보</span>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3">
+ <div>
+ <div className="text-xs text-gray-500 mb-1">계약구분</div>
+ <div className="font-medium text-sm text-gray-900">{contractTypeLabels[bidding.contractType]}</div>
</div>
- <div className="flex flex-col gap-1">
- <span className="text-gray-500 text-sm">유형</span>
- <span className="font-medium">{biddingTypeLabels[bidding.biddingType]}</span>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">입찰유형</div>
+ <div className="font-medium text-sm text-gray-900">{biddingTypeLabels[bidding.biddingType]}</div>
</div>
- <div className="flex flex-col gap-1">
- <span className="text-gray-500 text-sm">낙찰</span>
- <span className="font-medium">{bidding.awardCount === 'single' ? '단수' : '복수'}</span>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">낙찰수</div>
+ <div className="font-medium text-sm text-gray-900">
+ {bidding.awardCount ? awardCountLabels[bidding.awardCount] : '-'}
+ </div>
</div>
- <div className="flex flex-col gap-1">
- <span className="text-gray-500 text-sm">통화</span>
- <span className="font-mono font-medium">{bidding.currency}</span>
+ <div>
+ <div className="text-xs text-gray-500 mb-1">통화</div>
+ <div className="font-mono font-medium text-sm text-gray-900">{bidding.currency}</div>
</div>
</div>
+
+ {(bidding.contractStartDate || bidding.contractEndDate) && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">계약기간</div>
+ <div className="font-medium text-sm text-gray-900">
+ {bidding.contractStartDate && formatDate(bidding.contractStartDate, 'KR')}
+ {bidding.contractStartDate && bidding.contractEndDate && ' ~ '}
+ {bidding.contractEndDate && formatDate(bidding.contractEndDate, 'KR')}
+ </div>
+ </div>
+ )}
</div>
- {/* 오른쪽 섹션: 일정 정보 */}
- {(bidding.submissionStartDate || bidding.evaluationDate || bidding.preQuoteDate || bidding.biddingRegistrationDate) && (
- <div className="w-full border-l border-gray-100 pl-6">
- <div className="flex items-center gap-2 mb-3 text-sm text-gray-500">
- <Calendar className="w-4 h-4" />
- <span>일정 정보</span>
- </div>
- <div className="space-y-3">
- {bidding.submissionStartDate && bidding.submissionEndDate && (
- <div>
- <span className="text-gray-500 text-sm">제출기간</span>
- <div className="font-medium">
- {formatDate(bidding.submissionStartDate, 'KR')} ~ {formatDate(bidding.submissionEndDate, 'KR')}
- </div>
- </div>
- )}
- {bidding.biddingRegistrationDate && (
- <div>
- <span className="text-gray-500 text-sm">입찰등록일</span>
- <div className="font-medium">{formatDate(bidding.biddingRegistrationDate, 'KR')}</div>
- </div>
- )}
- {bidding.preQuoteDate && (
- <div>
- <span className="text-gray-500 text-sm">사전견적일</span>
- <div className="font-medium">{formatDate(bidding.preQuoteDate, 'KR')}</div>
- </div>
- )}
- {bidding.evaluationDate && (
- <div>
- <span className="text-gray-500 text-sm">평가일</span>
- <div className="font-medium">{formatDate(bidding.evaluationDate, 'KR')}</div>
- </div>
- )}
- </div>
+ {/* 4. 일정 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6 space-y-4">
+ <div className="flex items-center gap-2 text-sm font-semibold text-gray-700 mb-3">
+ <Calendar className="w-4 h-4" />
+ <span>일정 정보</span>
</div>
- )}
+
+ {bidding.biddingRegistrationDate && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">입찰등록일</div>
+ <div className="font-medium text-sm text-gray-900">{formatDate(bidding.biddingRegistrationDate, 'KR')}</div>
+ </div>
+ )}
+
+ {bidding.preQuoteDate && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">사전견적일</div>
+ <div className="font-medium text-sm text-gray-900">{formatDate(bidding.preQuoteDate, 'KR')}</div>
+ </div>
+ )}
+
+ {bidding.submissionStartDate && bidding.submissionEndDate && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">제출기간</div>
+ <div className="font-medium text-sm text-gray-900">
+ {formatDate(bidding.submissionStartDate, 'KR')}
+ <div className="text-xs text-gray-400">~</div>
+ {formatDate(bidding.submissionEndDate, 'KR')}
+ </div>
+ </div>
+ )}
+
+ {bidding.evaluationDate && (
+ <div>
+ <div className="text-xs text-gray-500 mb-1">평가일</div>
+ <div className="font-medium text-sm text-gray-900">{formatDate(bidding.evaluationDate, 'KR')}</div>
+ </div>
+ )}
+ </div>
</div>
</div>
)
diff --git a/components/bidding/bidding-round-actions.tsx b/components/bidding/bidding-round-actions.tsx
new file mode 100644
index 00000000..b2db0dfb
--- /dev/null
+++ b/components/bidding/bidding-round-actions.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useTransition } from "react"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { RefreshCw, RotateCw } from "lucide-react"
+import { increaseRoundOrRebid } from "@/lib/bidding/service"
+import { useToast } from "@/hooks/use-toast"
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import { useSession } from "next-auth/react"
+
+interface BiddingRoundActionsProps {
+ biddingId: number
+ biddingStatus?: string
+}
+
+export function BiddingRoundActions({ biddingId, biddingStatus }: BiddingRoundActionsProps) {
+ const router = useRouter()
+ const { toast } = useToast()
+ const [isPending, startTransition] = useTransition()
+ const [showRoundDialog, setShowRoundDialog] = React.useState(false)
+ const [showRebidDialog, setShowRebidDialog] = React.useState(false)
+ const { data: session } = useSession()
+ const userId = session?.user?.id
+
+ // 차수증가는 유찰 상태에서만 가능
+ const canIncreaseRound = biddingStatus === 'bidding_disposal'
+
+ // 재입찰도 유찰 상태에서만 가능
+ const canRebid = biddingStatus === 'bidding_disposal'
+
+ const handleRoundIncrease = () => {
+ startTransition(async () => {
+ try {
+ const result = await increaseRoundOrRebid(biddingId, userId, 'round_increase')
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ variant: "default",
+ })
+ setShowRoundDialog(false)
+ // 새로 생성된 입찰 페이지로 이동
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}`)
+ router.refresh()
+ }
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "차수증가 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('차수증가 실패:', error)
+ toast({
+ title: "오류",
+ description: "차수증가 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ })
+ }
+
+ const handleRebid = () => {
+ startTransition(async () => {
+ try {
+ const result = await increaseRoundOrRebid(biddingId, userId, 'rebidding')
+
+ if (result.success) {
+ toast({
+ title: "성공",
+ description: result.message,
+ variant: "default",
+ })
+ setShowRebidDialog(false)
+ // 새로 생성된 입찰 페이지로 이동
+ if (result.biddingId) {
+ router.push(`/evcp/bid/${result.biddingId}`)
+ router.refresh()
+ }
+ } else {
+ toast({
+ title: "오류",
+ description: result.error || "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ } catch (error) {
+ console.error('재입찰 실패:', error)
+ toast({
+ title: "오류",
+ description: "재입찰 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ })
+ }
+
+ // 유찰 상태가 아니면 컴포넌트를 렌더링하지 않음
+ if (!canIncreaseRound && !canRebid) {
+ return null
+ }
+
+ return (
+ <>
+ <Card className="mt-6">
+ <CardHeader>
+ <CardTitle>입찰 차수 관리</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex gap-4">
+ <Button
+ variant="outline"
+ onClick={() => setShowRoundDialog(true)}
+ disabled={!canIncreaseRound || isPending}
+ className="flex items-center gap-2"
+ >
+ <RotateCw className="w-4 h-4" />
+ 차수증가
+ </Button>
+ <Button
+ variant="outline"
+ onClick={() => setShowRebidDialog(true)}
+ disabled={!canRebid || isPending}
+ className="flex items-center gap-2"
+ >
+ <RefreshCw className="w-4 h-4" />
+ 재입찰
+ </Button>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ 유찰 상태에서 차수증가 또는 재입찰을 진행할 수 있습니다.
+ </p>
+ </CardContent>
+ </Card>
+
+ {/* 차수증가 확인 다이얼로그 */}
+ <AlertDialog open={showRoundDialog} onOpenChange={setShowRoundDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>차수증가</AlertDialogTitle>
+ <AlertDialogDescription>
+ 현재 입찰의 정보를 복제하여 새로운 차수의 입찰을 생성합니다.
+ <br />
+ 기존 입찰 조건, 아이템, 벤더 정보가 복제되며, 벤더 제출 정보는 초기화됩니다.
+ <br />
+ <br />
+ 계속하시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
+ <AlertDialogAction onClick={handleRoundIncrease} disabled={isPending}>
+ {isPending ? "처리중..." : "확인"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+
+ {/* 재입찰 확인 다이얼로그 */}
+ <AlertDialog open={showRebidDialog} onOpenChange={setShowRebidDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>재입찰</AlertDialogTitle>
+ <AlertDialogDescription>
+ 현재 입찰의 정보를 복제하여 재입찰을 생성합니다.
+ <br />
+ 기존 입찰 조건, 아이템, 벤더 정보가 복제되며, 벤더 제출 정보는 초기화됩니다.
+ <br />
+ <br />
+ 계속하시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isPending}>취소</AlertDialogCancel>
+ <AlertDialogAction onClick={handleRebid} disabled={isPending}>
+ {isPending ? "처리중..." : "확인"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
+}
+
+
diff --git a/components/bidding/create/bidding-create-dialog.tsx b/components/bidding/create/bidding-create-dialog.tsx
new file mode 100644
index 00000000..4ef403c9
--- /dev/null
+++ b/components/bidding/create/bidding-create-dialog.tsx
@@ -0,0 +1,1281 @@
+'use client'
+
+import * as React from 'react'
+import { UseFormReturn } from 'react-hook-form'
+import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign, Plus } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} 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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+
+import type { CreateBiddingSchema } from '@/lib/bidding/validation'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeTypeLabels } from '@/db/schema'
+import {
+ getIncotermsForSelection,
+ getPaymentTermsForSelection,
+ getPlaceOfShippingForSelection,
+ getPlaceOfDestinationForSelection,
+} from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
+import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
+import TiptapEditor from '@/components/qna/tiptap-editor'
+import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code'
+import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
+import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
+import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
+import { createBidding } from '@/lib/bidding/service'
+
+interface BiddingCreateDialogProps {
+ form: UseFormReturn<CreateBiddingSchema>
+ onSuccess?: () => void
+}
+
+export function BiddingCreateDialog({ form, onSuccess }: BiddingCreateDialogProps) {
+ const [isOpen, setIsOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+
+ const [biddingConditions, setBiddingConditions] = React.useState({
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+
+ // 구매요청자 정보 (현재 사용자)
+ // React.useEffect(() => {
+ // // 실제로는 현재 로그인한 사용자의 정보를 가져와야 함
+ // // 임시로 기본값 설정
+ // form.setValue('requesterName', '김두진') // 실제로는 API에서 가져와야 함
+ // }, [form])
+
+ const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([])
+ const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([])
+
+ // 담당자 selector 상태
+ const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
+ const [selectedSupplyPic, setSelectedSupplyPic] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+
+ // 입찰공고 템플릿 관련 상태
+ const [noticeTemplate, setNoticeTemplate] = React.useState<string>('')
+ const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false)
+
+ // -- 데이터 로딩 및 상태 동기화 로직
+ const loadPaymentTerms = React.useCallback(async () => {
+ try {
+ const data = await getPaymentTermsForSelection()
+ setPaymentTermsOptions(data)
+ const p008Exists = data.some((item) => item.code === 'P008')
+ if (p008Exists) {
+ setBiddingConditions((prev) => ({ ...prev, paymentTerms: 'P008' }))
+ form.setValue('biddingConditions.paymentTerms', 'P008')
+ }
+ } catch (error) {
+ console.error('Failed to load payment terms:', error)
+ toast.error('결제조건 목록을 불러오는데 실패했습니다.')
+ }
+ }, [form])
+
+ const loadIncoterms = React.useCallback(async () => {
+ try {
+ const data = await getIncotermsForSelection()
+ setIncotermsOptions(data)
+ const dapExists = data.some((item) => item.code === 'DAP')
+ if (dapExists) {
+ setBiddingConditions((prev) => ({ ...prev, incoterms: 'DAP' }))
+ form.setValue('biddingConditions.incoterms', 'DAP')
+ }
+ } catch (error) {
+ console.error('Failed to load incoterms:', error)
+ toast.error('운송조건 목록을 불러오는데 실패했습니다.')
+ }
+ }, [form])
+
+ const loadShippingPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfShippingForSelection()
+ setShippingPlaces(data)
+ } catch (error) {
+ console.error('Failed to load shipping places:', error)
+ toast.error('선적지 목록을 불러오는데 실패했습니다.')
+ }
+ }, [])
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfDestinationForSelection()
+ setDestinationPlaces(data)
+ } catch (error) {
+ console.error('Failed to load destination places:', error)
+ toast.error('하역지 목록을 불러오는데 실패했습니다.')
+ }
+ }, [])
+
+ React.useEffect(() => {
+ if (isOpen) {
+ loadPaymentTerms()
+ loadIncoterms()
+ loadShippingPlaces()
+ loadDestinationPlaces()
+ const v1Exists = TAX_CONDITIONS.some((item) => item.code === 'V1')
+ if (v1Exists) {
+ setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' }))
+ form.setValue('biddingConditions.taxConditions', 'V1')
+ }
+
+ // 초기 표준 템플릿 로드
+ const loadInitialTemplate = async () => {
+ try {
+ const standardTemplate = await getBiddingNoticeTemplate('standard')
+ if (standardTemplate) {
+ console.log('standardTemplate', standardTemplate)
+ setNoticeTemplate(standardTemplate.content)
+ form.setValue('content', standardTemplate.content)
+ }
+ } catch (error) {
+ console.error('Failed to load initial template:', error)
+ toast.error('기본 템플릿을 불러오는데 실패했습니다.')
+ }
+ }
+ loadInitialTemplate()
+ }
+ }, [isOpen, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces, form])
+
+ // 입찰공고 템플릿 로딩
+ const noticeTypeValue = form.watch('noticeType')
+ const selectedNoticeType = React.useMemo(() => noticeTypeValue, [noticeTypeValue])
+
+ React.useEffect(() => {
+ const loadNoticeTemplate = async () => {
+ if (selectedNoticeType) {
+ setIsLoadingTemplate(true)
+ try {
+ const template = await getBiddingNoticeTemplate(selectedNoticeType)
+ if (template) {
+ setNoticeTemplate(template.content)
+ // 폼의 content 필드도 업데이트
+ form.setValue('content', template.content)
+ } else {
+ // 템플릿이 없으면 표준 템플릿 사용
+ const defaultTemplate = await getBiddingNoticeTemplate('standard')
+ if (defaultTemplate) {
+ setNoticeTemplate(defaultTemplate.content)
+ form.setValue('content', defaultTemplate.content)
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load notice template:', error)
+ toast.error('입찰공고 템플릿을 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoadingTemplate(false)
+ }
+ }
+ }
+
+ loadNoticeTemplate()
+ }, [selectedNoticeType, form])
+
+ // SHI용 파일 첨부 핸들러
+ const handleShiFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = Array.from(event.target.files || [])
+ setShiAttachmentFiles(prev => [...prev, ...files])
+ }
+
+ const removeShiFile = (index: number) => {
+ setShiAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 협력업체용 파일 첨부 핸들러
+ const handleVendorFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = Array.from(event.target.files || [])
+ setVendorAttachmentFiles(prev => [...prev, ...files])
+ }
+
+ const removeVendorFile = (index: number) => {
+ setVendorAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 입찰담당자 선택 핸들러
+ const handleBidPicSelect = (code: PurchaseGroupCodeWithUser) => {
+ setSelectedBidPic(code)
+ form.setValue('bidPicName', code.DISPLAY_NAME || '')
+ form.setValue('bidPicCode', code.PURCHASE_GROUP_CODE || '')
+ // ID도 저장 (실제로는 사용자 ID가 필요)
+ if (code.user) {
+ form.setValue('bidPicId', code.user.id || undefined)
+ }
+ }
+
+ // 조달담당자 선택 핸들러
+ const handleSupplyPicSelect = (manager: ProcurementManagerWithUser) => {
+ setSelectedSupplyPic(manager)
+ form.setValue('supplyPicName', manager.DISPLAY_NAME || '')
+ form.setValue('supplyPicCode', manager.PROCUREMENT_MANAGER_CODE || '')
+ // ID도 저장 (실제로는 사용자 ID가 필요)
+ if (manager.user) {
+ form.setValue('supplyPicId', manager.user.id || undefined)
+ }
+ }
+
+ const handleSubmit = async (data: CreateBiddingSchema) => {
+ setIsSubmitting(true)
+ try {
+ // 폼 validation 실행
+ const isFormValid = await form.trigger()
+
+ if (!isFormValid) {
+ toast.error('필수 정보를 모두 입력해주세요.')
+ return
+ }
+
+ // 첨부파일 정보 설정 (실제로는 파일 업로드 후 저장해야 함)
+ const attachments = shiAttachmentFiles.map((file, index) => ({
+ id: `shi_${Date.now()}_${index}`,
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '', // 실제 업로드 후 경로
+ uploadedAt: new Date().toISOString(),
+ type: 'shi' as const,
+ }))
+
+ const vendorAttachments = vendorAttachmentFiles.map((file, index) => ({
+ id: `vendor_${Date.now()}_${index}`,
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '', // 실제 업로드 후 경로
+ uploadedAt: new Date().toISOString(),
+ type: 'vendor' as const,
+ }))
+
+ // sparePartOptions가 undefined인 경우 빈 문자열로 설정
+ const biddingData: CreateBiddingInput = {
+ ...data,
+ attachments,
+ vendorAttachments,
+ biddingConditions: {
+ ...data.biddingConditions,
+ sparePartOptions: data.biddingConditions.sparePartOptions || '',
+ incotermsOption: data.biddingConditions.incotermsOption || '',
+ contractDeliveryDate: data.biddingConditions.contractDeliveryDate || '',
+ shippingPort: data.biddingConditions.shippingPort || '',
+ destinationPort: data.biddingConditions.destinationPort || '',
+ },
+ }
+
+ const result = await createBidding(biddingData, '1') // 실제로는 현재 사용자 ID
+
+ if (result.success) {
+ toast.success("입찰이 성공적으로 생성되었습니다.")
+ setIsOpen(false)
+ form.reset()
+ setShiAttachmentFiles([])
+ setVendorAttachmentFiles([])
+ setSelectedBidPic(undefined)
+ setSelectedSupplyPic(undefined)
+ setNoticeTemplate('')
+ if (onSuccess) {
+ onSuccess()
+ }
+ } else {
+ toast.error((result as { success: false; error: string }).error || "입찰 생성에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Failed to create bidding:", error)
+ toast.error("입찰 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ setIsOpen(open)
+ if (!open) {
+ // 다이얼로그 닫을 때 폼 초기화
+ form.reset()
+ setShiAttachmentFiles([])
+ setVendorAttachmentFiles([])
+ setSelectedBidPic(undefined)
+ setSelectedSupplyPic(undefined)
+ setNoticeTemplate('')
+ setBiddingConditions({
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ <Button>
+ <Plus className="mr-2 h-4 w-4" />
+ 입찰 신규생성
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>입찰 신규생성</DialogTitle>
+ <DialogDescription>
+ 새로운 입찰을 생성합니다. 기본 정보와 입찰 조건을 설정하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* 통합된 기본 정보 및 입찰 조건 카드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 기본 정보 및 입찰 조건
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 1행: 입찰명, 낙찰수, 입찰유형, 계약구분 */}
+ <div className="grid grid-cols-4 gap-4">
+ <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="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="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>
+ )}
+ />
+
+ <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>
+ )}
+ />
+ </div>
+
+ {/* 기타 입찰유형 선택 시 직접입력 필드 */}
+ {form.watch('biddingType') === 'other' && (
+ <div className="grid grid-cols-4 gap-4">
+ <div></div>
+ <div></div>
+ <FormField
+ control={form.control}
+ name="biddingTypeCustom"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="직접 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <div></div>
+ </div>
+ )}
+
+ {/* 2행: 예산, 실적가, 내정가, P/R번호 (조회용) */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField
+ control={form.control}
+ name="budget"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 예산
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="예산 입력" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="finalBidPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 실적가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="실적가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="targetPrice"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Eye className="h-3 w-3" />
+ 내정가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="내정가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="prNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Eye className="h-3 w-3" />
+ P/R번호
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="P/R번호" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 3행: 입찰담당자, 조달담당자 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="bidPicName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 입찰담당자 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <PurchaseGroupCodeSelector
+ selectedCode={selectedBidPic}
+ onCodeSelect={(code) => {
+ handleBidPicSelect(code)
+ field.onChange(code.DISPLAY_NAME || '')
+ }}
+ placeholder="입찰담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="supplyPicName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 조달담당자
+ </FormLabel>
+ <FormControl>
+ <ProcurementManagerSelector
+ selectedManager={selectedSupplyPic}
+ onManagerSelect={(manager) => {
+ handleSupplyPicSelect(manager)
+ field.onChange(manager.DISPLAY_NAME || '')
+ }}
+ placeholder="조달담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 4행: 하도급법적용여부, SHI 지급조건 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="biddingConditions.isPriceAdjustmentApplicable"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>하도급법적용여부</FormLabel>
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="price-adjustment"
+ checked={biddingConditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))
+ field.onChange(checked)
+ }}
+ />
+ <FormLabel htmlFor="price-adjustment" className="text-sm">
+ 연동제 적용 요건
+ </FormLabel>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.paymentTerms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SHI 지급조건 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Select
+ value={biddingConditions.paymentTerms}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ paymentTerms: value
+ }))
+ field.onChange(value)
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="지급조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 5행: SHI 인도조건, SHI 인도조건2 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="biddingConditions.incoterms"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SHI 인도조건 <span className="text-red-500">*</span></FormLabel>
+ <Select
+ value={biddingConditions.incoterms}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incoterms: value
+ }))
+ field.onChange(value)
+ }}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.incotermsOption"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SHI 인도조건2</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="인도조건 추가 정보"
+ value={biddingConditions.incotermsOption}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incotermsOption: e.target.value
+ }))
+ field.onChange(e.target.value)
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 6행: SHI 매입부가가치세, SHI 선적지 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="biddingConditions.taxConditions"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SHI 매입부가가치세 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Select
+ value={biddingConditions.taxConditions}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ taxConditions: value
+ }))
+ field.onChange(value)
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="세금조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {TAX_CONDITIONS.map((condition) => (
+ <SelectItem key={condition.code} value={condition.code}>
+ {condition.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.shippingPort"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SHI 선적지</FormLabel>
+ <Select
+ value={biddingConditions.shippingPort}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ shippingPort: value
+ }))
+ field.onChange(value)
+ }}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="선적지 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 7행: SHI 하역지, 계약 납품일 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="biddingConditions.destinationPort"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>SHI 하역지</FormLabel>
+ <Select
+ value={biddingConditions.destinationPort}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ destinationPort: value
+ }))
+ field.onChange(value)
+ }}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="하역지 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.contractDeliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약 납품일</FormLabel>
+ <FormControl>
+ <Input
+ type="date"
+ value={biddingConditions.contractDeliveryDate}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))
+ field.onChange(e.target.value)
+ }}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 8행: 계약기간 시작/종료, 진행상태, 구매조직 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField
+ control={form.control}
+ name="contractStartDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 시작
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contractEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 종료
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Eye className="h-3 w-3" />
+ 진행상태
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="진행상태" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="purchasingOrganization"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Building className="h-3 w-3" />
+ 구매조직
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value || ''}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매조직 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 9행: 구매요청자, 구매유형, 통화, 스페어파트 옵션 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField
+ control={form.control}
+ name="requesterName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 구매요청자
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="구매요청자" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="noticeType"
+ 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(biddingNoticeTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <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>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="biddingConditions.sparePartOptions"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>스페어파트 옵션</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={biddingConditions.sparePartOptions}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))
+ field.onChange(e.target.value)
+ }}
+ rows={3}
+ className="min-h-[40px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 입찰개요 추가 */}
+ <div className="mt-6 pt-4 border-t">
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰개요</FormLabel>
+ <FormControl>
+ <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={3} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 입찰공고 내용 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>입찰공고 내용</CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 선택한 입찰공고 타입의 템플릿이 자동으로 로드됩니다. 필요에 따라 수정하세요.
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name="content"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <div className="border rounded-lg">
+ <TiptapEditor
+ content={field.value || noticeTemplate}
+ setContent={(content) => {
+ field.onChange(content)
+ }}
+ disabled={isLoadingTemplate}
+ height="300px"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {isLoadingTemplate && (
+ <div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
+ 입찰공고 템플릿을 불러오는 중...
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* SHI용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ SHI용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ SHI에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={handleShiFileUpload}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {shiAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <div className="space-y-2">
+ {shiAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeShiFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 협력업체용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ 협력업체용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 협력업체에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={handleVendorFileUpload}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {vendorAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <div className="space-y-2">
+ {vendorAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeVendorFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 네비게이션 버튼 */}
+ <div className="flex justify-between">
+ <div></div>
+ <div className="flex gap-2">
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ className="flex items-center gap-2"
+ >
+ {isSubmitting ? '저장 중...' : '저장'}
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/components/bidding/manage/bidding-basic-info-editor.tsx b/components/bidding/manage/bidding-basic-info-editor.tsx
new file mode 100644
index 00000000..d60c5d88
--- /dev/null
+++ b/components/bidding/manage/bidding-basic-info-editor.tsx
@@ -0,0 +1,1407 @@
+'use client'
+
+import * as React from 'react'
+import { useForm } from 'react-hook-form'
+import { ChevronRight, Upload, FileText, Eye, User, Building, Calendar, DollarSign } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} 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'
+// CreateBiddingInput 타입 정의가 없으므로 CreateBiddingSchema를 확장하여 사용합니다.
+import { getBiddingById, updateBiddingBasicInfo, getBiddingConditions, getBiddingNotice, updateBiddingConditions } from '@/lib/bidding/service'
+import { getBiddingNoticeTemplate } from '@/lib/bidding/service'
+import {
+ getIncotermsForSelection,
+ getPaymentTermsForSelection,
+ getPlaceOfShippingForSelection,
+ getPlaceOfDestinationForSelection,
+} from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels, biddingNoticeTypeLabels } from '@/db/schema'
+import TiptapEditor from '@/components/qna/tiptap-editor'
+import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code'
+import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager'
+import type { PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code/purchase-group-code-service'
+import type { ProcurementManagerWithUser } from '@/components/common/selectors/procurement-manager/procurement-manager-service'
+import { getBiddingDocuments, uploadBiddingDocument, deleteBiddingDocument } from '@/lib/bidding/detail/service'
+import { downloadFile } from '@/lib/file-download'
+
+// 입찰 기본 정보 에디터 컴포넌트
+interface BiddingBasicInfo {
+ title?: string
+ description?: string
+ content?: string
+ noticeType?: string
+ contractType?: string
+ biddingType?: string
+ biddingTypeCustom?: string
+ awardCount?: string
+ budget?: string
+ finalBidPrice?: string
+ targetPrice?: string
+ prNumber?: string
+ contractStartDate?: string
+ contractEndDate?: string
+ submissionStartDate?: string
+ submissionEndDate?: string
+ evaluationDate?: string
+ hasSpecificationMeeting?: boolean
+ hasPrDocument?: boolean
+ currency?: string
+ purchasingOrganization?: string
+ bidPicName?: string
+ bidPicCode?: string
+ supplyPicName?: string
+ supplyPicCode?: string
+ requesterName?: string
+ remarks?: string
+}
+
+interface BiddingBasicInfoEditorProps {
+ biddingId: number
+}
+
+interface UploadedDocument {
+ id: number
+ biddingId: number
+ companyId: number | null
+ documentType: string
+ fileName: string
+ originalFileName: string
+ fileSize: number | null
+ filePath: string
+ title: string | null
+ description: string | null
+ uploadedAt: string
+ uploadedBy: string
+}
+
+export function BiddingBasicInfoEditor({ biddingId }: BiddingBasicInfoEditorProps) {
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false)
+ const [noticeTemplate, setNoticeTemplate] = React.useState('')
+
+ // 첨부파일 관련 상태
+ const [shiAttachmentFiles, setShiAttachmentFiles] = React.useState<File[]>([])
+ const [vendorAttachmentFiles, setVendorAttachmentFiles] = React.useState<File[]>([])
+ const [existingDocuments, setExistingDocuments] = React.useState<UploadedDocument[]>([])
+ const [isLoadingDocuments, setIsLoadingDocuments] = React.useState(false)
+
+ // 담당자 selector 상태
+ const [selectedBidPic, setSelectedBidPic] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
+ const [selectedSupplyPic, setSelectedSupplyPic] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+
+ // 입찰 조건 관련 상태
+ const [biddingConditions, setBiddingConditions] = React.useState({
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+
+ // Procurement 데이터 상태들
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{ code: string; description: string }>>([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{ code: string; description: string }>>([])
+
+ const form = useForm<BiddingBasicInfo>({
+ defaultValues: {}
+ })
+
+
+ // 공고 템플릿 로드 - 현재 저장된 템플릿 우선
+ const loadNoticeTemplate = React.useCallback(async (noticeType?: string) => {
+ setIsLoadingTemplate(true)
+ try {
+ // 먼저 현재 입찰에 저장된 템플릿이 있는지 확인
+ const savedNotice = await getBiddingNotice(biddingId)
+ if (savedNotice && savedNotice.content) {
+ setNoticeTemplate(savedNotice.content)
+ const currentContent = form.getValues('content')
+ if (!currentContent || currentContent.trim() === '') {
+ form.setValue('content', savedNotice.content)
+ }
+ setIsLoadingTemplate(false)
+ return
+ }
+
+ // 저장된 템플릿이 없으면 타입별 템플릿 로드
+ if (noticeType) {
+ const template = await getBiddingNoticeTemplate(noticeType)
+ if (template) {
+ setNoticeTemplate(template.content || '')
+ const currentContent = form.getValues('content')
+ if (!currentContent || currentContent.trim() === '') {
+ form.setValue('content', template.content || '')
+ }
+ } else {
+ // 템플릿이 없으면 표준 템플릿 사용
+ const defaultTemplate = await getBiddingNoticeTemplate('standard')
+ if (defaultTemplate) {
+ setNoticeTemplate(defaultTemplate.content)
+ const currentContent = form.getValues('content')
+ if (!currentContent || currentContent.trim() === '') {
+ form.setValue('content', defaultTemplate.content)
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.warn('Failed to load notice template:', error)
+ } finally {
+ setIsLoadingTemplate(false)
+ }
+ }, [biddingId, form])
+
+ // 데이터 로딩
+ React.useEffect(() => {
+ const loadBiddingData = async () => {
+ setIsLoading(true)
+ try {
+ const bidding = await getBiddingById(biddingId)
+ if (bidding) {
+ // 타입 확장된 bidding 객체
+ const biddingExtended = bidding as typeof bidding & {
+ content?: string | null
+ noticeType?: string | null
+ biddingTypeCustom?: string | null
+ awardCount?: string | null
+ requesterName?: string | null
+ }
+
+ // 날짜를 문자열로 변환하는 헬퍼
+ const formatDate = (date: unknown): string => {
+ if (!date) return ''
+ if (typeof date === 'string') return date.split('T')[0]
+ if (date instanceof Date) return date.toISOString().split('T')[0]
+ return ''
+ }
+
+ const formatDateTime = (date: unknown): string => {
+ if (!date) return ''
+ if (typeof date === 'string') return date.slice(0, 16)
+ if (date instanceof Date) return date.toISOString().slice(0, 16)
+ return ''
+ }
+
+ // 폼 데이터 설정
+ form.reset({
+ title: bidding.title || '',
+ description: bidding.description || '',
+ content: biddingExtended.content || '',
+ noticeType: biddingExtended.noticeType || '',
+ contractType: bidding.contractType || '',
+ biddingType: bidding.biddingType || '',
+ biddingTypeCustom: biddingExtended.biddingTypeCustom || '',
+ awardCount: biddingExtended.awardCount || (bidding.awardCount ? String(bidding.awardCount) : ''),
+ budget: bidding.budget ? bidding.budget.toString() : '',
+ finalBidPrice: bidding.finalBidPrice ? bidding.finalBidPrice.toString() : '',
+ targetPrice: bidding.targetPrice ? bidding.targetPrice.toString() : '',
+ prNumber: bidding.prNumber || '',
+ contractStartDate: formatDate(bidding.contractStartDate),
+ contractEndDate: formatDate(bidding.contractEndDate),
+ submissionStartDate: formatDateTime(bidding.submissionStartDate),
+ submissionEndDate: formatDateTime(bidding.submissionEndDate),
+ evaluationDate: formatDateTime(bidding.evaluationDate),
+ hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
+ hasPrDocument: bidding.hasPrDocument || false,
+ currency: bidding.currency || 'KRW',
+ purchasingOrganization: bidding.purchasingOrganization || '',
+ bidPicName: bidding.bidPicName || '',
+ bidPicCode: bidding.bidPicCode || '',
+ supplyPicName: bidding.supplyPicName || '',
+ supplyPicCode: bidding.supplyPicCode || '',
+ requesterName: biddingExtended.requesterName || '',
+ remarks: bidding.remarks || '',
+ })
+
+ // 입찰 조건 로드
+ const conditions = await getBiddingConditions(biddingId)
+ if (conditions) {
+ setBiddingConditions({
+ paymentTerms: conditions.paymentTerms || '',
+ taxConditions: conditions.taxConditions || 'V1',
+ incoterms: conditions.incoterms || 'DAP',
+ incotermsOption: conditions.incotermsOption || '',
+ contractDeliveryDate: conditions.contractDeliveryDate
+ ? new Date(conditions.contractDeliveryDate).toISOString().split('T')[0]
+ : '',
+ shippingPort: conditions.shippingPort || '',
+ destinationPort: conditions.destinationPort || '',
+ isPriceAdjustmentApplicable: conditions.isPriceAdjustmentApplicable || false,
+ sparePartOptions: conditions.sparePartOptions || '',
+ })
+ }
+
+ // Procurement 데이터 로드
+ const [paymentTermsData, incotermsData, shippingData, destinationData] = await Promise.all([
+ getPaymentTermsForSelection().catch(() => []),
+ getIncotermsForSelection().catch(() => []),
+ getPlaceOfShippingForSelection().catch(() => []),
+ getPlaceOfDestinationForSelection().catch(() => []),
+ ])
+ setPaymentTermsOptions(paymentTermsData)
+ setIncotermsOptions(incotermsData)
+ setShippingPlaces(shippingData)
+ setDestinationPlaces(destinationData)
+
+ // 공고 템플릿 로드
+ await loadNoticeTemplate(biddingExtended.noticeType || undefined)
+ } else {
+ toast.error('입찰 정보를 찾을 수 없습니다.')
+ }
+ } catch (error) {
+ console.error('Error loading bidding data:', error)
+ toast.error('입찰 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadBiddingData()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [biddingId, loadNoticeTemplate])
+
+ // 구매유형 변경 시 템플릿 자동 로드
+ const noticeTypeValue = form.watch('noticeType')
+ React.useEffect(() => {
+ if (noticeTypeValue) {
+ loadNoticeTemplate(noticeTypeValue)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [noticeTypeValue])
+
+ // 기존 첨부파일 로드
+ const loadExistingDocuments = async () => {
+ setIsLoadingDocuments(true)
+ try {
+ const docs = await getBiddingDocuments(biddingId)
+ const mappedDocs = docs.map((doc) => ({
+ ...doc,
+ uploadedAt: doc.uploadedAt?.toString() || '',
+ uploadedBy: doc.uploadedBy || ''
+ }))
+ setExistingDocuments(mappedDocs)
+ } catch (error) {
+ console.error('Failed to load documents:', error)
+ toast.error('첨부파일 목록을 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoadingDocuments(false)
+ }
+ }
+
+ // 초기 로드 시 첨부파일도 함께 로드
+ React.useEffect(() => {
+ if (biddingId) {
+ loadExistingDocuments()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [biddingId])
+
+ // SHI용 파일 첨부 핸들러
+ const handleShiFileUpload = async (files: File[]) => {
+ try {
+ // 파일을 업로드하고 기존 문서 목록 갱신
+ for (const file of files) {
+ const result = await uploadBiddingDocument(
+ biddingId,
+ file,
+ 'bid_attachment',
+ file.name,
+ 'SHI용 첨부파일',
+ '1' // TODO: 실제 사용자 ID 가져오기
+ )
+ if (result.success) {
+ toast.success(`${file.name} 업로드 완료`)
+ }
+ }
+ await loadExistingDocuments()
+ setShiAttachmentFiles([])
+ } catch (error) {
+ console.error('Failed to upload SHI files:', error)
+ toast.error('파일 업로드에 실패했습니다.')
+ }
+ }
+
+ const removeShiFile = (index: number) => {
+ setShiAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 협력업체용 파일 첨부 핸들러
+ const handleVendorFileUpload = async (files: File[]) => {
+ try {
+ // 파일을 업로드하고 기존 문서 목록 갱신
+ for (const file of files) {
+ const result = await uploadBiddingDocument(
+ biddingId,
+ file,
+ 'bid_attachment',
+ file.name,
+ '협력업체용 첨부파일',
+ '1' // TODO: 실제 사용자 ID 가져오기
+ )
+ if (result.success) {
+ toast.success(`${file.name} 업로드 완료`)
+ }
+ }
+ await loadExistingDocuments()
+ setVendorAttachmentFiles([])
+ } catch (error) {
+ console.error('Failed to upload vendor files:', error)
+ toast.error('파일 업로드에 실패했습니다.')
+ }
+ }
+
+ const removeVendorFile = (index: number) => {
+ setVendorAttachmentFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 파일 삭제
+ const handleDeleteDocument = async (documentId: number) => {
+ if (!confirm('이 파일을 삭제하시겠습니까?')) {
+ return
+ }
+
+ try {
+ const result = await deleteBiddingDocument(documentId, biddingId, '1') // TODO: 실제 사용자 ID 가져오기
+ if (result.success) {
+ toast.success('파일이 삭제되었습니다.')
+ await loadExistingDocuments()
+ } else {
+ toast.error(result.error || '파일 삭제에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to delete document:', error)
+ toast.error('파일 삭제에 실패했습니다.')
+ }
+ }
+
+ // 파일 다운로드
+ const handleDownloadDocument = async (document: UploadedDocument) => {
+ try {
+ await downloadFile(document.filePath, document.originalFileName, {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Failed to download document:', error)
+ toast.error('파일 다운로드에 실패했습니다.')
+ }
+ }
+
+ // 입찰담당자 선택 핸들러
+ const handleBidPicSelect = (code: PurchaseGroupCodeWithUser) => {
+ setSelectedBidPic(code)
+ form.setValue('bidPicName', code.DISPLAY_NAME || '')
+ form.setValue('bidPicCode', code.PURCHASE_GROUP_CODE || '')
+ }
+
+ // 조달담당자 선택 핸들러
+ const handleSupplyPicSelect = (manager: ProcurementManagerWithUser) => {
+ setSelectedSupplyPic(manager)
+ form.setValue('supplyPicName', manager.DISPLAY_NAME || '')
+ form.setValue('supplyPicCode', manager.PROCUREMENT_MANAGER_CODE || '')
+ }
+
+ // 파일 크기 포맷팅
+ const formatFileSize = (bytes: number | null) => {
+ if (!bytes) return '-'
+ if (bytes === 0) return '0 Bytes'
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+ }
+
+ // 저장 처리
+ const handleSave = async (data: BiddingBasicInfo) => {
+ setIsSubmitting(true)
+ try {
+ // 기본 정보 저장
+ const result = await updateBiddingBasicInfo(biddingId, data, '1') // TODO: 실제 사용자 ID 가져오기
+
+ if (result.success) {
+ // 입찰 조건 저장
+ const conditionsResult = await updateBiddingConditions(biddingId, {
+ paymentTerms: biddingConditions.paymentTerms,
+ taxConditions: biddingConditions.taxConditions,
+ incoterms: biddingConditions.incoterms,
+ incotermsOption: biddingConditions.incotermsOption,
+ contractDeliveryDate: biddingConditions.contractDeliveryDate || undefined,
+ shippingPort: biddingConditions.shippingPort,
+ destinationPort: biddingConditions.destinationPort,
+ isPriceAdjustmentApplicable: biddingConditions.isPriceAdjustmentApplicable,
+ sparePartOptions: biddingConditions.sparePartOptions,
+ })
+
+ if (conditionsResult.success) {
+ toast.success('입찰 기본 정보와 조건이 성공적으로 저장되었습니다.')
+ } else {
+ const errorMessage = 'error' in conditionsResult ? conditionsResult.error : '입찰 조건 저장에 실패했습니다.'
+ toast.error(errorMessage)
+ }
+ } else {
+ const errorMessage = 'error' in result ? result.error : '입찰 기본 정보 저장에 실패했습니다.'
+ toast.error(errorMessage)
+ }
+ } catch (error) {
+ console.error('Failed to save bidding basic info:', error)
+ toast.error('입찰 기본 정보 저장에 실패했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">입찰 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 입찰 기본 정보
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 입찰의 기본 정보를 수정할 수 있습니다.
+ </p>
+ </CardHeader>
+ <CardContent>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSave)} className="space-y-4">
+ {/* 1행: 입찰명, PR번호, 입찰유형, 계약구분 */}
+ <div className="grid grid-cols-4 gap-4">
+ <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="prNumber" render={({ field }) => (
+ <FormItem>
+ <FormLabel>PR 번호</FormLabel>
+ <FormControl>
+ <Input placeholder="PR 번호를 입력하세요" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="biddingType" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰유형</FormLabel>
+ <Select onValueChange={field.onChange} value={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>
+ )} />
+
+ <FormField control={form.control} name="contractType" render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약구분</FormLabel>
+ <Select onValueChange={field.onChange} value={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>
+ )} />
+ </div>
+
+ {/* 기타 입찰유형 선택 시 직접입력 필드 */}
+ {form.watch('biddingType') === 'other' && (
+ <div className="grid grid-cols-4 gap-4">
+ <div></div>
+ <div></div>
+ <FormField control={form.control} name="biddingTypeCustom" render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="직접 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+ <div></div>
+ </div>
+ )}
+
+ {/* 2행: 예산, 실적가, 내정가, 낙찰수 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="budget" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 예산
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="예산 입력" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="finalBidPrice" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <DollarSign className="h-3 w-3" />
+ 실적가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="실적가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="targetPrice" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Eye className="h-3 w-3" />
+ 내정가
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="내정가" {...field} readOnly className="bg-muted" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="awardCount" render={({ field }) => (
+ <FormItem>
+ <FormLabel>낙찰수</FormLabel>
+ <Select onValueChange={field.onChange} value={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>
+ )} />
+ </div>
+
+ {/* 3행: 입찰담당자, 조달담당자, 구매조직, 통화 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="bidPicName" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 입찰담당자
+ </FormLabel>
+ <FormControl>
+ <PurchaseGroupCodeSelector
+ selectedCode={selectedBidPic}
+ onCodeSelect={(code) => {
+ handleBidPicSelect(code)
+ field.onChange(code.DISPLAY_NAME || '')
+ }}
+ placeholder="입찰담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="supplyPicName" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <User className="h-3 w-3" />
+ 조달담당자
+ </FormLabel>
+ <FormControl>
+ <ProcurementManagerSelector
+ selectedManager={selectedSupplyPic}
+ onManagerSelect={(manager) => {
+ handleSupplyPicSelect(manager)
+ field.onChange(manager.DISPLAY_NAME || '')
+ }}
+ placeholder="조달담당자 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="purchasingOrganization" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Building className="h-3 w-3" />
+ 구매조직
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매조직 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="currency" render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화</FormLabel>
+ <Select onValueChange={field.onChange} value={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>
+
+ {/* 구매유형 필드 추가 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="noticeType" render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매유형 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매유형 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(biddingNoticeTypeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )} />
+ <div></div>
+ <div></div>
+ <div></div>
+ </div>
+
+ {/* 4행: 계약기간 시작/종료, 입찰서 제출 시작/마감 */}
+ <div className="grid grid-cols-4 gap-4">
+ <FormField control={form.control} name="contractStartDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 시작
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="contractEndDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ <Calendar className="h-3 w-3" />
+ 계약기간 종료
+ </FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ {/* <FormField control={form.control} name="submissionStartDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰서 제출 시작</FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ <FormField control={form.control} name="submissionEndDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰서 제출 마감</FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} /> */}
+ </div>
+
+ {/* 5행: 개찰 일시, 사양설명회, PR문서 */}
+ {/* <div className="grid grid-cols-3 gap-4">
+ <FormField control={form.control} name="evaluationDate" render={({ field }) => (
+ <FormItem>
+ <FormLabel>개찰 일시</FormLabel>
+ <FormControl>
+ <Input type="datetime-local" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} /> */}
+
+ {/* <FormField control={form.control} name="hasSpecificationMeeting" render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">사양설명회</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 사양설명회가 필요한 경우 체크
+ </div>
+ </div>
+ <FormControl>
+ <Switch checked={field.value} onCheckedChange={field.onChange} />
+ </FormControl>
+ </FormItem>
+ )} /> */}
+
+ {/* <FormField control={form.control} name="hasPrDocument" render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">PR 문서</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ PR 문서가 있는 경우 체크
+ </div>
+ </div>
+ <FormControl>
+ <Switch checked={field.value} onCheckedChange={field.onChange} />
+ </FormControl>
+ </FormItem>
+ )} /> */}
+ {/* </div> */}
+
+ {/* 입찰개요 */}
+ <div className="pt-2">
+ <FormField control={form.control} name="description" render={({ field }) => (
+ <FormItem>
+ <FormLabel>입찰개요</FormLabel>
+ <FormControl>
+ <Textarea placeholder="입찰에 대한 설명을 입력하세요" rows={2} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 비고 */}
+ <div className="pt-2">
+ <FormField control={form.control} name="remarks" render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea placeholder="추가 사항이나 참고사항을 입력하세요" rows={3} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+ </div>
+
+ {/* 입찰 조건 */}
+ <div className="pt-4 border-t">
+ <CardTitle className="text-lg mb-4">입찰 조건</CardTitle>
+
+ {/* 1행: SHI 지급조건, SHI 매입부가가치세 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <FormLabel>SHI 지급조건 <span className="text-red-500">*</span></FormLabel>
+ <Select
+ value={biddingConditions.paymentTerms}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ paymentTerms: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="지급조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <FormLabel>SHI 매입부가가치세 <span className="text-red-500">*</span></FormLabel>
+ <Select
+ value={biddingConditions.taxConditions}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ taxConditions: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="세금조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {TAX_CONDITIONS.map((condition) => (
+ <SelectItem key={condition.code} value={condition.code}>
+ {condition.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ {/* 2행: SHI 인도조건, SHI 인도조건2 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <FormLabel>SHI 인도조건 <span className="text-red-500">*</span></FormLabel>
+ <Select
+ value={biddingConditions.incoterms}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incoterms: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <FormLabel>SHI 인도조건2</FormLabel>
+ <Input
+ placeholder="인도조건 추가 정보"
+ value={biddingConditions.incotermsOption}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ incotermsOption: e.target.value
+ }))
+ }}
+ />
+ </div>
+ </div>
+
+ {/* 3행: SHI 선적지, SHI 하역지 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <FormLabel>SHI 선적지</FormLabel>
+ <Select
+ value={biddingConditions.shippingPort}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ shippingPort: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선적지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <FormLabel>SHI 하역지</FormLabel>
+ <Select
+ value={biddingConditions.destinationPort}
+ onValueChange={(value) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ destinationPort: value
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="하역지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ {/* 4행: 계약 납품일, 연동제 적용 가능 */}
+ <div className="grid grid-cols-2 gap-4 mb-4">
+ <div>
+ <FormLabel>계약 납품일</FormLabel>
+ <Input
+ type="date"
+ value={biddingConditions.contractDeliveryDate}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))
+ }}
+ />
+ </div>
+
+ <div className="flex flex-row items-center justify-between rounded-lg border p-3">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">연동제 적용 가능</FormLabel>
+ <div className="text-sm text-muted-foreground">
+ 연동제 적용 요건 여부
+ </div>
+ </div>
+ <Switch
+ checked={biddingConditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))
+ }}
+ />
+ </div>
+ </div>
+
+ {/* 5행: 스페어파트 옵션 */}
+ <div className="mb-4">
+ <div>
+ <FormLabel>스페어파트 옵션</FormLabel>
+ <Textarea
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={biddingConditions.sparePartOptions}
+ onChange={(e) => {
+ setBiddingConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))
+ }}
+ rows={3}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 입찰공고 내용 */}
+ <div className="pt-4 border-t">
+ <CardTitle className="text-lg mb-4">입찰공고 내용</CardTitle>
+ <FormField control={form.control} name="content" render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <div className="border rounded-lg">
+ <TiptapEditor
+ content={field.value || noticeTemplate}
+ setContent={field.onChange}
+ disabled={isLoadingTemplate}
+ height="300px"
+ />
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )} />
+
+ {isLoadingTemplate && (
+ <div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
+ 입찰공고 템플릿을 불러오는 중...
+ </div>
+ )}
+ </div>
+
+ {/* 액션 버튼 */}
+ <div className="flex justify-end gap-4 pt-4">
+ <Button type="submit" disabled={isSubmitting} className="flex items-center gap-2">
+ {isSubmitting ? '저장 중...' : '저장'}
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
+
+ {/* SHI용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ SHI용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ SHI에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={(e) => {
+ const files = Array.from(e.target.files || [])
+ setShiAttachmentFiles(prev => [...prev, ...files])
+ }}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {shiAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드 예정 파일</h4>
+ <div className="space-y-2">
+ {shiAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ handleShiFileUpload([file])
+ }}
+ >
+ 업로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeShiFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 기존 문서 목록 */}
+ {isLoadingDocuments ? (
+ <div className="flex items-center justify-center p-4 text-sm text-muted-foreground">
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
+ 문서 목록을 불러오는 중...
+ </div>
+ ) : existingDocuments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 문서</h4>
+ <div className="space-y-2">
+ {existingDocuments
+ .filter(doc => doc.description?.includes('SHI용') || doc.title?.includes('SHI'))
+ .map((doc) => (
+ <div
+ key={doc.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{doc.originalFileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatFileSize(doc.fileSize)} • {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadDocument(doc)}
+ >
+ 다운로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteDocument(doc.id)}
+ >
+ 삭제
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 협력업체용 첨부파일 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ 협력업체용 첨부파일
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 협력업체에서 제공하는 문서나 파일을 업로드하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="border-2 border-dashed border-gray-300 rounded-lg p-6">
+ <div className="text-center">
+ <Upload className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <div className="space-y-2">
+ <p className="text-sm text-gray-600">
+ 파일을 드래그 앤 드롭하거나{' '}
+ <label className="text-blue-600 hover:text-blue-500 cursor-pointer">
+ <input
+ type="file"
+ multiple
+ className="hidden"
+ onChange={(e) => {
+ const files = Array.from(e.target.files || [])
+ setVendorAttachmentFiles(prev => [...prev, ...files])
+ }}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg"
+ />
+ 찾아보세요
+ </label>
+ </p>
+ <p className="text-xs text-gray-500">
+ PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, JPEG 파일을 업로드할 수 있습니다
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {vendorAttachmentFiles.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드 예정 파일</h4>
+ <div className="space-y-2">
+ {vendorAttachmentFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{file.name}</p>
+ <p className="text-xs text-muted-foreground">
+ {(file.size / 1024 / 1024).toFixed(2)} MB
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ handleVendorFileUpload([file])
+ }}
+ >
+ 업로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeVendorFile(index)}
+ >
+ 제거
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 기존 문서 목록 */}
+ {existingDocuments.length > 0 && (
+ <div className="space-y-2">
+ <h4 className="text-sm font-medium">업로드된 문서</h4>
+ <div className="space-y-2">
+ {existingDocuments
+ .filter(doc => doc.description?.includes('협력업체용') || !doc.description?.includes('SHI용'))
+ .map((doc) => (
+ <div
+ key={doc.id}
+ className="flex items-center justify-between p-3 bg-muted rounded-lg"
+ >
+ <div className="flex items-center gap-3">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <p className="text-sm font-medium">{doc.originalFileName}</p>
+ <p className="text-xs text-muted-foreground">
+ {formatFileSize(doc.fileSize)} • {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
+ </p>
+ </div>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDownloadDocument(doc)}
+ >
+ 다운로드
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteDocument(doc.id)}
+ >
+ 삭제
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/bidding/manage/bidding-companies-editor.tsx b/components/bidding/manage/bidding-companies-editor.tsx
new file mode 100644
index 00000000..1ce8b014
--- /dev/null
+++ b/components/bidding/manage/bidding-companies-editor.tsx
@@ -0,0 +1,803 @@
+'use client'
+
+import * as React from 'react'
+import { Building, User, Plus, Trash2 } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import {
+ getBiddingVendors,
+ getBiddingCompanyContacts,
+ createBiddingCompanyContact,
+ deleteBiddingCompanyContact,
+ getVendorContactsByVendorId,
+ updateBiddingCompanyPriceAdjustmentQuestion
+} from '@/lib/bidding/service'
+import { deleteBiddingCompany } from '@/lib/bidding/pre-quote/service'
+import { BiddingDetailVendorCreateDialog } from './bidding-detail-vendor-create-dialog'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Loader2 } from 'lucide-react'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+
+interface QuotationVendor {
+ id: number // biddingCompanies.id
+ companyId?: number // vendors.id (벤더 ID)
+ vendorName: string
+ vendorCode: string
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ quotationAmount?: number
+ currency: string
+ invitationStatus: string
+ isPriceAdjustmentApplicableQuestion?: boolean
+}
+
+interface BiddingCompaniesEditorProps {
+ biddingId: number
+}
+
+interface VendorContact {
+ id: number
+ vendorId: number
+ contactName: string
+ contactPosition: string | null
+ contactDepartment: string | null
+ contactTask: string | null
+ contactEmail: string
+ contactPhone: string | null
+ isPrimary: boolean
+}
+
+interface BiddingCompanyContact {
+ id: number
+ biddingId: number
+ vendorId: number
+ contactName: string
+ contactEmail: string
+ contactNumber: string | null
+ createdAt: Date
+ updatedAt: Date
+}
+
+export function BiddingCompaniesEditor({ biddingId }: BiddingCompaniesEditorProps) {
+ const [vendors, setVendors] = React.useState<QuotationVendor[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [addVendorDialogOpen, setAddVendorDialogOpen] = React.useState(false)
+ const [selectedVendor, setSelectedVendor] = React.useState<QuotationVendor | null>(null)
+ const [biddingCompanyContacts, setBiddingCompanyContacts] = React.useState<BiddingCompanyContact[]>([])
+ const [isLoadingContacts, setIsLoadingContacts] = React.useState(false)
+ // 각 업체별 첫 번째 담당자 정보 저장 (vendorId -> 첫 번째 담당자)
+ const [vendorFirstContacts, setVendorFirstContacts] = React.useState<Map<number, BiddingCompanyContact>>(new Map())
+
+ // 담당자 추가 다이얼로그
+ const [addContactDialogOpen, setAddContactDialogOpen] = React.useState(false)
+ const [newContact, setNewContact] = React.useState({
+ contactName: '',
+ contactEmail: '',
+ contactNumber: '',
+ })
+ const [addContactFromVendorDialogOpen, setAddContactFromVendorDialogOpen] = React.useState(false)
+ const [vendorContacts, setVendorContacts] = React.useState<VendorContact[]>([])
+ const [isLoadingVendorContacts, setIsLoadingVendorContacts] = React.useState(false)
+ const [selectedContactFromVendor, setSelectedContactFromVendor] = React.useState<VendorContact | null>(null)
+
+ // 업체 목록 다시 로딩 함수
+ const reloadVendors = React.useCallback(async () => {
+ try {
+ const result = await getBiddingVendors(biddingId)
+ if (result.success && result.data) {
+ const vendorsList = result.data.map(v => ({
+ ...v,
+ companyId: v.companyId || undefined,
+ vendorName: v.vendorName || '',
+ vendorCode: v.vendorCode || '',
+ contactPerson: v.contactPerson ?? undefined,
+ contactEmail: v.contactEmail ?? undefined,
+ contactPhone: v.contactPhone ?? undefined,
+ quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined,
+ isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false,
+ }))
+ setVendors(vendorsList)
+
+ // 각 업체별 첫 번째 담당자 정보 로드
+ const firstContactsMap = new Map<number, BiddingCompanyContact>()
+ const contactPromises = vendorsList
+ .filter(v => v.companyId)
+ .map(async (vendor) => {
+ try {
+ const contactResult = await getBiddingCompanyContacts(biddingId, vendor.companyId!)
+ if (contactResult.success && contactResult.data && contactResult.data.length > 0) {
+ firstContactsMap.set(vendor.companyId!, contactResult.data[0])
+ }
+ } catch (error) {
+ console.error(`Failed to load contact for vendor ${vendor.companyId}:`, error)
+ }
+ })
+
+ await Promise.all(contactPromises)
+ setVendorFirstContacts(firstContactsMap)
+ }
+ } catch (error) {
+ console.error('Failed to reload vendors:', error)
+ }
+ }, [biddingId])
+
+ // 데이터 로딩
+ React.useEffect(() => {
+ const loadVendors = async () => {
+ setIsLoading(true)
+ try {
+ const result = await getBiddingVendors(biddingId)
+ if (result.success && result.data) {
+ const vendorsList = result.data.map(v => ({
+ id: v.id,
+ companyId: v.companyId || undefined,
+ vendorName: v.vendorName || '',
+ vendorCode: v.vendorCode || '',
+ contactPerson: v.contactPerson !== null ? v.contactPerson : undefined,
+ contactEmail: v.contactEmail !== null ? v.contactEmail : undefined,
+ contactPhone: v.contactPhone !== null ? v.contactPhone : undefined,
+ quotationAmount: v.quotationAmount ? parseFloat(v.quotationAmount) : undefined,
+ currency: v.currency || 'KRW',
+ invitationStatus: v.invitationStatus,
+ isPriceAdjustmentApplicableQuestion: v.isPriceAdjustmentApplicableQuestion ?? false,
+ }))
+ setVendors(vendorsList)
+
+ // 각 업체별 첫 번째 담당자 정보 로드
+ const firstContactsMap = new Map<number, BiddingCompanyContact>()
+ const contactPromises = vendorsList
+ .filter(v => v.companyId)
+ .map(async (vendor) => {
+ try {
+ const contactResult = await getBiddingCompanyContacts(biddingId, vendor.companyId!)
+ if (contactResult.success && contactResult.data && contactResult.data.length > 0) {
+ firstContactsMap.set(vendor.companyId!, contactResult.data[0])
+ }
+ } catch (error) {
+ console.error(`Failed to load contact for vendor ${vendor.companyId}:`, error)
+ }
+ })
+
+ await Promise.all(contactPromises)
+ setVendorFirstContacts(firstContactsMap)
+ } else {
+ toast.error(result.error || '업체 정보를 불러오는데 실패했습니다.')
+ setVendors([])
+ }
+ } catch (error) {
+ console.error('Failed to load vendors:', error)
+ toast.error('업체 정보를 불러오는데 실패했습니다.')
+ setVendors([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadVendors()
+ }, [biddingId])
+
+ // 업체 선택 핸들러 (단일 선택)
+ const handleVendorSelect = async (vendor: QuotationVendor) => {
+ // 이미 선택된 업체를 다시 클릭하면 선택 해제
+ if (selectedVendor?.id === vendor.id) {
+ setSelectedVendor(null)
+ setBiddingCompanyContacts([])
+ return
+ }
+
+ // 새 업체 선택
+ setSelectedVendor(vendor)
+
+ // 선택한 업체의 담당자 목록 로딩
+ if (vendor.companyId) {
+ setIsLoadingContacts(true)
+ try {
+ const result = await getBiddingCompanyContacts(biddingId, vendor.companyId)
+ if (result.success && result.data) {
+ setBiddingCompanyContacts(result.data)
+ } else {
+ toast.error(result.error || '담당자 목록을 불러오는데 실패했습니다.')
+ setBiddingCompanyContacts([])
+ }
+ } catch (error) {
+ console.error('Failed to load contacts:', error)
+ toast.error('담당자 목록을 불러오는데 실패했습니다.')
+ setBiddingCompanyContacts([])
+ } finally {
+ setIsLoadingContacts(false)
+ }
+ }
+ }
+
+ // 업체 삭제
+ const handleRemoveVendor = async (vendorId: number) => {
+ if (!confirm('정말로 이 업체를 삭제하시겠습니까?')) {
+ return
+ }
+
+ try {
+ const result = await deleteBiddingCompany(vendorId)
+ if (result.success) {
+ toast.success('업체가 삭제되었습니다.')
+ // 업체 목록 다시 로딩
+ await reloadVendors()
+ // 선택된 업체가 삭제된 경우 담당자 목록도 초기화
+ if (selectedVendor?.id === vendorId) {
+ setSelectedVendor(null)
+ setBiddingCompanyContacts([])
+ }
+ } else {
+ toast.error(result.error || '업체 삭제에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to remove vendor:', error)
+ toast.error('업체 삭제에 실패했습니다.')
+ }
+ }
+
+ // 담당자 추가 (직접 입력)
+ const handleAddContact = async () => {
+ if (!selectedVendor || !selectedVendor.companyId) {
+ toast.error('업체를 선택해주세요.')
+ return
+ }
+
+ if (!newContact.contactName || !newContact.contactEmail) {
+ toast.error('이름과 이메일은 필수입니다.')
+ return
+ }
+
+ try {
+ const result = await createBiddingCompanyContact(
+ biddingId,
+ selectedVendor.companyId,
+ {
+ contactName: newContact.contactName,
+ contactEmail: newContact.contactEmail,
+ contactNumber: newContact.contactNumber || undefined,
+ }
+ )
+
+ if (result.success) {
+ toast.success('담당자가 추가되었습니다.')
+ setAddContactDialogOpen(false)
+ setNewContact({ contactName: '', contactEmail: '', contactNumber: '' })
+
+ // 담당자 목록 새로고침
+ const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setBiddingCompanyContacts(contactsResult.data)
+ // 첫 번째 담당자 정보 업데이트
+ if (contactsResult.data.length > 0) {
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.set(selectedVendor.companyId!, contactsResult.data[0])
+ return newMap
+ })
+ }
+ }
+ } else {
+ toast.error(result.error || '담당자 추가에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to add contact:', error)
+ toast.error('담당자 추가에 실패했습니다.')
+ }
+ }
+
+ // 담당자 추가 (벤더 목록에서 선택)
+ const handleOpenAddContactFromVendor = async () => {
+ if (!selectedVendor || !selectedVendor.companyId) {
+ toast.error('업체를 선택해주세요.')
+ return
+ }
+
+ setIsLoadingVendorContacts(true)
+ setAddContactFromVendorDialogOpen(true)
+ setSelectedContactFromVendor(null)
+
+ try {
+ const result = await getVendorContactsByVendorId(selectedVendor.companyId)
+ if (result.success && result.data) {
+ setVendorContacts(result.data)
+ } else {
+ toast.error(result.error || '벤더 담당자 목록을 불러오는데 실패했습니다.')
+ setVendorContacts([])
+ }
+ } catch (error) {
+ console.error('Failed to load vendor contacts:', error)
+ toast.error('벤더 담당자 목록을 불러오는데 실패했습니다.')
+ setVendorContacts([])
+ } finally {
+ setIsLoadingVendorContacts(false)
+ }
+ }
+
+ // 벤더 담당자 선택 후 저장
+ const handleAddContactFromVendor = async () => {
+ if (!selectedContactFromVendor || !selectedVendor || !selectedVendor.companyId) {
+ toast.error('담당자를 선택해주세요.')
+ return
+ }
+
+ try {
+ const result = await createBiddingCompanyContact(
+ biddingId,
+ selectedVendor.companyId,
+ {
+ contactName: selectedContactFromVendor.contactName,
+ contactEmail: selectedContactFromVendor.contactEmail,
+ contactNumber: selectedContactFromVendor.contactPhone || undefined,
+ }
+ )
+
+ if (result.success) {
+ toast.success('담당자가 추가되었습니다.')
+ setAddContactFromVendorDialogOpen(false)
+ setSelectedContactFromVendor(null)
+
+ // 담당자 목록 새로고침
+ const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setBiddingCompanyContacts(contactsResult.data)
+ // 첫 번째 담당자 정보 업데이트
+ if (contactsResult.data.length > 0) {
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.set(selectedVendor.companyId!, contactsResult.data[0])
+ return newMap
+ })
+ }
+ }
+ } else {
+ toast.error(result.error || '담당자 추가에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to add contact:', error)
+ toast.error('담당자 추가에 실패했습니다.')
+ }
+ }
+
+ // 담당자 삭제
+ const handleDeleteContact = async (contactId: number) => {
+ if (!confirm('정말로 이 담당자를 삭제하시겠습니까?')) {
+ return
+ }
+
+ try {
+ const result = await deleteBiddingCompanyContact(contactId)
+ if (result.success) {
+ toast.success('담당자가 삭제되었습니다.')
+
+ // 담당자 목록 새로고침
+ if (selectedVendor && selectedVendor.companyId) {
+ const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setBiddingCompanyContacts(contactsResult.data)
+ // 첫 번째 담당자 정보 업데이트
+ if (contactsResult.data.length > 0) {
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.set(selectedVendor.companyId!, contactsResult.data[0])
+ return newMap
+ })
+ } else {
+ // 담당자가 없으면 Map에서 제거
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.delete(selectedVendor.companyId!)
+ return newMap
+ })
+ }
+ }
+ }
+ } else {
+ toast.error(result.error || '담당자 삭제에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to delete contact:', error)
+ toast.error('담당자 삭제에 실패했습니다.')
+ }
+ }
+
+ // 연동제 적용요건 문의 체크박스 변경
+ const handleTogglePriceAdjustmentQuestion = async (vendorId: number, checked: boolean) => {
+ try {
+ const result = await updateBiddingCompanyPriceAdjustmentQuestion(vendorId, checked)
+ if (result.success) {
+ // 로컬 상태 업데이트
+ setVendors(prev => prev.map(v =>
+ v.id === vendorId
+ ? { ...v, isPriceAdjustmentApplicableQuestion: checked }
+ : v
+ ))
+
+ // 선택된 업체 정보도 업데이트
+ if (selectedVendor?.id === vendorId) {
+ setSelectedVendor(prev => prev ? { ...prev, isPriceAdjustmentApplicableQuestion: checked } : null)
+ }
+
+ // 담당자 목록 새로고침 (첫 번째 담당자 정보 업데이트를 위해)
+ if (selectedVendor && selectedVendor.companyId) {
+ const contactsResult = await getBiddingCompanyContacts(biddingId, selectedVendor.companyId)
+ if (contactsResult.success && contactsResult.data) {
+ setBiddingCompanyContacts(contactsResult.data)
+ if (contactsResult.data.length > 0) {
+ setVendorFirstContacts(prev => {
+ const newMap = new Map(prev)
+ newMap.set(selectedVendor.companyId!, contactsResult.data[0])
+ return newMap
+ })
+ }
+ }
+ }
+ } else {
+ toast.error(result.error || '연동제 적용요건 문의 여부 업데이트에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Failed to update price adjustment question:', error)
+ toast.error('연동제 적용요건 문의 여부 업데이트에 실패했습니다.')
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">업체 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 참여 업체 목록 테이블 */}
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <Building className="h-5 w-5" />
+ 참여 업체 목록
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 입찰에 참여하는 업체들을 관리합니다. 업체를 선택하면 하단에 담당자 목록이 표시됩니다.
+ </p>
+ </div>
+ <Button onClick={() => setAddVendorDialogOpen(true)} className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ 업체 추가
+ </Button>
+ </CardHeader>
+ <CardContent>
+ {vendors.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 참여 업체가 없습니다. 업체를 추가해주세요.
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px]">선택</TableHead>
+ <TableHead>업체명</TableHead>
+ <TableHead>업체코드</TableHead>
+ <TableHead>담당자 이름</TableHead>
+ <TableHead>담당자 이메일</TableHead>
+ <TableHead>담당자 연락처</TableHead>
+ <TableHead>상태</TableHead>
+ <TableHead className="w-[180px]">연동제 적용요건 문의</TableHead>
+ <TableHead className="w-[100px]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {vendors.map((vendor) => (
+ <TableRow
+ key={vendor.id}
+ className={`cursor-pointer hover:bg-muted/50 ${selectedVendor?.id === vendor.id ? 'bg-muted/50' : ''}`}
+ onClick={() => handleVendorSelect(vendor)}
+ >
+ <TableCell onClick={(e) => e.stopPropagation()}>
+ <Checkbox
+ checked={selectedVendor?.id === vendor.id}
+ onCheckedChange={() => handleVendorSelect(vendor)}
+ />
+ </TableCell>
+ <TableCell className="font-medium">{vendor.vendorName}</TableCell>
+ <TableCell>{vendor.vendorCode}</TableCell>
+ <TableCell>
+ {vendor.companyId && vendorFirstContacts.has(vendor.companyId)
+ ? vendorFirstContacts.get(vendor.companyId)!.contactName
+ : '-'}
+ </TableCell>
+ <TableCell>
+ {vendor.companyId && vendorFirstContacts.has(vendor.companyId)
+ ? vendorFirstContacts.get(vendor.companyId)!.contactEmail
+ : '-'}
+ </TableCell>
+ <TableCell>
+ {vendor.companyId && vendorFirstContacts.has(vendor.companyId)
+ ? vendorFirstContacts.get(vendor.companyId)!.contactNumber || '-'
+ : '-'}
+ </TableCell>
+
+ <TableCell>
+ <span className="px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
+ {vendor.invitationStatus}
+ </span>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={vendor.isPriceAdjustmentApplicableQuestion || false}
+ onCheckedChange={(checked) =>
+ handleTogglePriceAdjustmentQuestion(vendor.id, checked as boolean)
+ }
+ />
+ <span className="text-sm text-muted-foreground">
+ {vendor.isPriceAdjustmentApplicableQuestion ? '예' : '아니오'}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveVendor(vendor.id)}
+ className="text-red-600 hover:text-red-800"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 선택한 업체의 담당자 목록 테이블 */}
+ {selectedVendor && (
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <User className="h-5 w-5" />
+ {selectedVendor.vendorName} 담당자 목록
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 선택한 업체의 선정된 담당자를 관리합니다.
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={handleOpenAddContactFromVendor}
+ className="flex items-center gap-2"
+ >
+ <User className="h-4 w-4" />
+ 업체 담당자 추가
+ </Button>
+ <Button
+ onClick={() => setAddContactDialogOpen(true)}
+ className="flex items-center gap-2"
+ >
+ <Plus className="h-4 w-4" />
+ 담당자 수기 입력
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent>
+ {isLoadingContacts ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">담당자 목록을 불러오는 중...</span>
+ </div>
+ ) : biddingCompanyContacts.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 등록된 담당자가 없습니다. 담당자를 추가해주세요.
+ </div>
+ ) : (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>이름</TableHead>
+ <TableHead>이메일</TableHead>
+ <TableHead>전화번호</TableHead>
+ <TableHead className="w-[100px]">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {biddingCompanyContacts.map((biddingCompanyContact) => (
+ <TableRow key={biddingCompanyContact.id}>
+ <TableCell className="font-medium">{biddingCompanyContact.contactName}</TableCell>
+ <TableCell>{biddingCompanyContact.contactEmail}</TableCell>
+ <TableCell>{biddingCompanyContact.contactNumber || '-'}</TableCell>
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleDeleteContact(biddingCompanyContact.id)}
+ className="text-red-600 hover:text-red-800"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 업체 추가 다이얼로그 */}
+ <BiddingDetailVendorCreateDialog
+ biddingId={biddingId}
+ open={addVendorDialogOpen}
+ onOpenChange={setAddVendorDialogOpen}
+ onSuccess={reloadVendors}
+ />
+
+ {/* 담당자 추가 다이얼로그 (직접 입력) */}
+ <Dialog open={addContactDialogOpen} onOpenChange={setAddContactDialogOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>담당자 추가</DialogTitle>
+ <DialogDescription>
+ 새로운 담당자 정보를 입력하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="space-y-2">
+ <Label htmlFor="contactName">이름 *</Label>
+ <Input
+ id="contactName"
+ value={newContact.contactName}
+ onChange={(e) => setNewContact(prev => ({ ...prev, contactName: e.target.value }))}
+ placeholder="담당자 이름"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contactEmail">이메일 *</Label>
+ <Input
+ id="contactEmail"
+ type="email"
+ value={newContact.contactEmail}
+ onChange={(e) => setNewContact(prev => ({ ...prev, contactEmail: e.target.value }))}
+ placeholder="example@email.com"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="contactNumber">전화번호</Label>
+ <Input
+ id="contactNumber"
+ value={newContact.contactNumber}
+ onChange={(e) => setNewContact(prev => ({ ...prev, contactNumber: e.target.value }))}
+ placeholder="010-1234-5678"
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setAddContactDialogOpen(false)
+ setNewContact({ contactName: '', contactEmail: '', contactNumber: '' })
+ }}
+ >
+ 취소
+ </Button>
+ <Button onClick={handleAddContact}>
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* 벤더 담당자에서 추가 다이얼로그 */}
+ <Dialog open={addContactFromVendorDialogOpen} onOpenChange={setAddContactFromVendorDialogOpen}>
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>
+ {selectedVendor ? `${selectedVendor.vendorName} 벤더 담당자에서 선택` : '벤더 담당자 선택'}
+ </DialogTitle>
+ <DialogDescription>
+ 벤더에 등록된 담당자 목록에서 선택하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ {isLoadingVendorContacts ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+ <span className="text-sm text-muted-foreground">담당자 목록을 불러오는 중...</span>
+ </div>
+ ) : vendorContacts.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ 등록된 담당자가 없습니다.
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {vendorContacts.map((contact) => (
+ <div
+ key={contact.id}
+ className={`flex items-center justify-between p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors ${
+ selectedContactFromVendor?.id === contact.id ? 'bg-primary/10 border-primary' : ''
+ }`}
+ onClick={() => setSelectedContactFromVendor(contact)}
+ >
+ <div className="flex items-center gap-3 flex-1">
+ <Checkbox
+ checked={selectedContactFromVendor?.id === contact.id}
+ onCheckedChange={() => setSelectedContactFromVendor(contact)}
+ className="shrink-0"
+ />
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ {contact.isPrimary && (
+ <span className="px-2 py-0.5 rounded-full text-xs bg-primary/10 text-primary">
+ 주담당자
+ </span>
+ )}
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">{contact.contactPosition}</p>
+ )}
+ <div className="flex items-center gap-4 mt-1 text-sm text-muted-foreground">
+ <span>{contact.contactEmail}</span>
+ {contact.contactPhone && <span>{contact.contactPhone}</span>}
+ </div>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setAddContactFromVendorDialogOpen(false)
+ setSelectedContactFromVendor(null)
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleAddContactFromVendor}
+ disabled={!selectedContactFromVendor || isLoadingVendorContacts}
+ >
+ 추가
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
index d0f85b14..ed3e2be6 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx
+++ b/components/bidding/manage/bidding-detail-vendor-create-dialog.tsx
@@ -2,9 +2,7 @@
import * as React from 'react'
import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
@@ -15,13 +13,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import {
Command,
CommandEmpty,
CommandGroup,
@@ -34,8 +25,8 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
-import { Check, ChevronsUpDown, Search, Loader2, X, Plus } from 'lucide-react'
-import { cn } from '@/lib/utils'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { ChevronsUpDown, Loader2, X, Plus } from 'lucide-react'
import { createBiddingDetailVendor } from '@/lib/bidding/detail/service'
import { searchVendorsForBidding } from '@/lib/bidding/service'
import { useToast } from '@/hooks/use-toast'
@@ -57,6 +48,11 @@ interface Vendor {
status: string
}
+interface SelectedVendorWithQuestion {
+ vendor: Vendor
+ isPriceAdjustmentApplicableQuestion: boolean
+}
+
export function BiddingDetailVendorCreateDialog({
biddingId,
open,
@@ -65,17 +61,13 @@ export function BiddingDetailVendorCreateDialog({
}: BiddingDetailVendorCreateDialogProps) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
+ const [activeTab, setActiveTab] = React.useState('select')
// Vendor 검색 상태
const [vendorList, setVendorList] = React.useState<Vendor[]>([])
- const [selectedVendors, setSelectedVendors] = React.useState<Vendor[]>([])
+ const [selectedVendorsWithQuestion, setSelectedVendorsWithQuestion] = React.useState<SelectedVendorWithQuestion[]>([])
const [vendorOpen, setVendorOpen] = React.useState(false)
- // 폼 상태 (간소화 - 필수 항목만)
- const [formData, setFormData] = React.useState({
- awardRatio: 100, // 기본 100%
- })
-
// 벤더 로드
const loadVendors = React.useCallback(async () => {
try {
@@ -90,7 +82,7 @@ export function BiddingDetailVendorCreateDialog({
})
setVendorList([])
}
- }, [biddingId])
+ }, [biddingId, toast])
React.useEffect(() => {
if (open) {
@@ -101,33 +93,50 @@ export function BiddingDetailVendorCreateDialog({
// 초기화
React.useEffect(() => {
if (!open) {
- setSelectedVendors([])
- setFormData({
- awardRatio: 100, // 기본 100%
- })
+ setSelectedVendorsWithQuestion([])
+ setActiveTab('select')
}
}, [open])
// 벤더 추가
const handleAddVendor = (vendor: Vendor) => {
- if (!selectedVendors.find(v => v.id === vendor.id)) {
- setSelectedVendors([...selectedVendors, vendor])
+ if (!selectedVendorsWithQuestion.find(v => v.vendor.id === vendor.id)) {
+ setSelectedVendorsWithQuestion([
+ ...selectedVendorsWithQuestion,
+ {
+ vendor,
+ isPriceAdjustmentApplicableQuestion: false
+ }
+ ])
}
setVendorOpen(false)
}
// 벤더 제거
const handleRemoveVendor = (vendorId: number) => {
- setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId))
+ setSelectedVendorsWithQuestion(
+ selectedVendorsWithQuestion.filter(v => v.vendor.id !== vendorId)
+ )
}
// 이미 선택된 벤더인지 확인
const isVendorSelected = (vendorId: number) => {
- return selectedVendors.some(v => v.id === vendorId)
+ return selectedVendorsWithQuestion.some(v => v.vendor.id === vendorId)
+ }
+
+ // 연동제 적용요건 문의 체크박스 토글
+ const handleTogglePriceAdjustmentQuestion = (vendorId: number, checked: boolean) => {
+ setSelectedVendorsWithQuestion(prev =>
+ prev.map(item =>
+ item.vendor.id === vendorId
+ ? { ...item, isPriceAdjustmentApplicableQuestion: checked }
+ : item
+ )
+ )
}
const handleCreate = () => {
- if (selectedVendors.length === 0) {
+ if (selectedVendorsWithQuestion.length === 0) {
toast({
title: '오류',
description: '업체를 선택해주세요.',
@@ -136,24 +145,35 @@ export function BiddingDetailVendorCreateDialog({
return
}
+ // Tab 2로 이동하여 연동제 적용요건 문의를 확인하도록 유도
+ if (activeTab === 'select') {
+ setActiveTab('question')
+ toast({
+ title: '확인 필요',
+ description: '선택한 업체들의 연동제 적용요건 문의를 확인해주세요.',
+ })
+ return
+ }
+
startTransition(async () => {
let successCount = 0
- let errorMessages: string[] = []
+ const errorMessages: string[] = []
- for (const vendor of selectedVendors) {
+ for (const item of selectedVendorsWithQuestion) {
try {
const response = await createBiddingDetailVendor(
biddingId,
- vendor.id
+ item.vendor.id,
+ item.isPriceAdjustmentApplicableQuestion
)
if (response.success) {
successCount++
} else {
- errorMessages.push(`${vendor.vendorName}: ${response.error}`)
+ errorMessages.push(`${item.vendor.vendorName}: ${response.error}`)
}
- } catch (error) {
- errorMessages.push(`${vendor.vendorName}: 처리 중 오류가 발생했습니다.`)
+ } catch {
+ errorMessages.push(`${item.vendor.vendorName}: 처리 중 오류가 발생했습니다.`)
}
}
@@ -178,12 +198,12 @@ export function BiddingDetailVendorCreateDialog({
}
const resetForm = () => {
- setSelectedVendors([])
- setFormData({
- awardRatio: 100, // 기본 100%
- })
+ setSelectedVendorsWithQuestion([])
+ setActiveTab('select')
}
+ const selectedVendors = selectedVendorsWithQuestion.map(item => item.vendor)
+
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col">
@@ -191,14 +211,26 @@ export function BiddingDetailVendorCreateDialog({
<DialogHeader className="p-6 pb-0">
<DialogTitle>협력업체 추가</DialogTitle>
<DialogDescription>
- 입찰에 참여할 업체를 선택하세요. 여러 개 선택 가능합니다.
+ 입찰에 참여할 업체를 선택하고 연동제 적용요건 문의를 설정하세요.
</DialogDescription>
</DialogHeader>
- {/* 메인 컨텐츠 */}
- <div className="flex-1 px-6 py-4 overflow-y-auto">
- <div className="space-y-6">
- {/* 업체 선택 카드 */}
+ {/* 탭 네비게이션 */}
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col px-6">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="select">
+ 1. 입찰업체 선택 ({selectedVendors.length}개)
+ </TabsTrigger>
+ <TabsTrigger
+ value="question"
+ disabled={selectedVendors.length === 0}
+ >
+ 2. 연동제 적용요건 문의
+ </TabsTrigger>
+ </TabsList>
+
+ {/* Tab 1: 입찰업체 선택 */}
+ <TabsContent value="select" className="flex-1 overflow-y-auto mt-4 pb-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">업체 선택</CardTitle>
@@ -299,8 +331,66 @@ export function BiddingDetailVendorCreateDialog({
</div>
</CardContent>
</Card>
- </div>
- </div>
+ </TabsContent>
+
+ {/* Tab 2: 연동제 적용요건 문의 체크 */}
+ <TabsContent value="question" className="flex-1 overflow-y-auto mt-4 pb-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">연동제 적용요건 문의</CardTitle>
+ <CardDescription>
+ 선택한 업체별로 연동제 적용요건 문의 여부를 체크하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {selectedVendorsWithQuestion.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <p className="text-sm">선택된 업체가 없습니다.</p>
+ <p className="text-xs mt-1">먼저 입찰업체 선택 탭에서 업체를 선택해주세요.</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {selectedVendorsWithQuestion.map((item, index) => (
+ <div
+ key={item.vendor.id}
+ className="flex items-center justify-between p-4 rounded-lg border"
+ >
+ <div className="flex items-center gap-4 flex-1">
+ <span className="text-sm text-muted-foreground w-8">
+ {index + 1}.
+ </span>
+ <div className="flex-1">
+ <div className="flex items-center gap-2 mb-1">
+ <Badge variant="outline">
+ {item.vendor.vendorCode}
+ </Badge>
+ <span className="font-medium">{item.vendor.vendorName}</span>
+ </div>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <Checkbox
+ id={`question-${item.vendor.id}`}
+ checked={item.isPriceAdjustmentApplicableQuestion}
+ onCheckedChange={(checked) =>
+ handleTogglePriceAdjustmentQuestion(item.vendor.id, checked as boolean)
+ }
+ />
+ <Label
+ htmlFor={`question-${item.vendor.id}`}
+ className="text-sm cursor-pointer"
+ >
+ 연동제 적용요건 문의
+ </Label>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
{/* 푸터 */}
<DialogFooter className="p-6 pt-0 border-t">
@@ -311,18 +401,37 @@ export function BiddingDetailVendorCreateDialog({
>
취소
</Button>
- <Button
- onClick={handleCreate}
- disabled={isPending || selectedVendors.length === 0}
- >
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {selectedVendors.length > 0
- ? `${selectedVendors.length}개 업체 추가`
- : '업체 추가'
- }
- </Button>
+ {activeTab === 'select' ? (
+ <Button
+ onClick={() => {
+ if (selectedVendors.length > 0) {
+ setActiveTab('question')
+ } else {
+ toast({
+ title: '오류',
+ description: '업체를 선택해주세요.',
+ variant: 'destructive',
+ })
+ }
+ }}
+ disabled={isPending || selectedVendors.length === 0}
+ >
+ 다음 단계
+ </Button>
+ ) : (
+ <Button
+ onClick={handleCreate}
+ disabled={isPending || selectedVendorsWithQuestion.length === 0}
+ >
+ {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {selectedVendorsWithQuestion.length > 0
+ ? `${selectedVendorsWithQuestion.length}개 업체 추가`
+ : '업체 추가'
+ }
+ </Button>
+ )}
</DialogFooter>
</DialogContent>
</Dialog>
)
-}
+} \ No newline at end of file
diff --git a/components/bidding/manage/bidding-items-editor.tsx b/components/bidding/manage/bidding-items-editor.tsx
new file mode 100644
index 00000000..96a8d2ae
--- /dev/null
+++ b/components/bidding/manage/bidding-items-editor.tsx
@@ -0,0 +1,1143 @@
+'use client'
+
+import * as React from 'react'
+import { Package, Plus, Trash2, Save, RefreshCw, FileText } from 'lucide-react'
+import { getPRItemsForBidding } from '@/lib/bidding/detail/service'
+import { updatePrItem } from '@/lib/bidding/detail/service'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Checkbox } from '@/components/ui/checkbox'
+import { ProjectSelector } from '@/components/ProjectSelector'
+import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
+import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single'
+import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
+import { CostCenterSingleSelector } from '@/components/common/selectors/cost-center/cost-center-single-selector'
+import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector'
+
+// PR 아이템 정보 타입 (create-bidding-dialog와 동일)
+interface PRItemInfo {
+ id: number // 실제 DB ID
+ prNumber?: string | null
+ projectId?: number | null
+ projectInfo?: string | null
+ shi?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ materialDescription?: string | null
+ hasSpecDocument?: boolean
+ requestedDeliveryDate?: string | null
+ isRepresentative?: boolean // 대표 아이템 여부
+ // 가격 정보
+ annualUnitPrice?: string | null
+ currency?: string | null
+ // 자재 그룹 정보 (필수)
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ // 자재 정보
+ materialNumber?: string | null
+ materialInfo?: string | null
+ // 단위 정보
+ priceUnit?: string | null
+ purchaseUnit?: string | null
+ materialWeight?: string | null
+ // WBS 정보
+ wbsCode?: string | null
+ wbsName?: string | null
+ // Cost Center 정보
+ costCenterCode?: string | null
+ costCenterName?: string | null
+ // GL Account 정보
+ glAccountCode?: string | null
+ glAccountName?: string | null
+ // 내정 정보
+ targetUnitPrice?: string | null
+ targetAmount?: string | null
+ targetCurrency?: string | null
+ // 예산 정보
+ budgetAmount?: string | null
+ budgetCurrency?: string | null
+ // 실적 정보
+ actualAmount?: string | null
+ actualCurrency?: string | null
+}
+
+interface BiddingItemsEditorProps {
+ biddingId: number
+}
+
+import { removeBiddingItem, addPRItemForBidding, getBiddingById, getBiddingConditions } from '@/lib/bidding/service'
+import { CreatePreQuoteRfqDialog } from './create-pre-quote-rfq-dialog'
+import { Textarea } from '@/components/ui/textarea'
+import { Label } from '@/components/ui/label'
+
+export function BiddingItemsEditor({ biddingId }: BiddingItemsEditorProps) {
+ const { data: session } = useSession()
+ const [items, setItems] = React.useState<PRItemInfo[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity')
+ const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false)
+ const [selectedItemForCostCenter, setSelectedItemForCostCenter] = React.useState<number | null>(null)
+ const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false)
+ const [selectedItemForGlAccount, setSelectedItemForGlAccount] = React.useState<number | null>(null)
+ const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false)
+ const [selectedItemForWbs, setSelectedItemForWbs] = React.useState<number | null>(null)
+ const [tempIdCounter, setTempIdCounter] = React.useState(0) // 임시 ID 카운터
+ const [deletedItemIds, setDeletedItemIds] = React.useState<Set<number>>(new Set()) // 삭제된 아이템 ID 추적
+ const [preQuoteDialogOpen, setPreQuoteDialogOpen] = React.useState(false)
+ const [targetPriceCalculationCriteria, setTargetPriceCalculationCriteria] = React.useState('')
+ const [biddingPicUserId, setBiddingPicUserId] = React.useState<number | null>(null)
+ const [biddingConditions, setBiddingConditions] = React.useState<{
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ } | null>(null)
+
+ // 초기 데이터 로딩 - 기존 품목이 있으면 자동으로 로드
+ React.useEffect(() => {
+ const loadItems = async () => {
+ if (!biddingId) return
+
+ setIsLoading(true)
+ try {
+ const prItems = await getPRItemsForBidding(biddingId)
+
+ if (prItems && prItems.length > 0) {
+ const formattedItems: PRItemInfo[] = prItems.map((item) => ({
+ id: item.id,
+ prNumber: item.prNumber || null,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ quantity: item.quantity ? item.quantity.toString() : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? item.totalWeight.toString() : null,
+ weightUnit: item.weightUnit || null,
+ materialDescription: item.itemInfo || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null,
+ isRepresentative: false, // 첫 번째 아이템을 대표로 설정할 수 있음
+ annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null,
+ currency: item.currency || 'KRW',
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? item.materialWeight.toString() : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null,
+ targetAmount: item.targetAmount ? item.targetAmount.toString() : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? item.actualAmount.toString() : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ }))
+
+ // 첫 번째 아이템을 대표로 설정
+ formattedItems[0].isRepresentative = true
+
+ setItems(formattedItems)
+ setDeletedItemIds(new Set()) // 삭제 목록 초기화
+
+ // 기존 품목 로드 성공 알림 (조용히 표시, 선택적)
+ console.log(`기존 품목 ${formattedItems.length}개를 불러왔습니다.`)
+ } else {
+ // 품목이 없을 때는 빈 배열로 초기화
+ setItems([])
+ setDeletedItemIds(new Set())
+ }
+ } catch (error) {
+ console.error('Failed to load items:', error)
+ toast.error('품목 정보를 불러오는데 실패했습니다.')
+ // 에러 발생 시에도 빈 배열로 초기화하여 UI가 깨지지 않도록
+ setItems([])
+ setDeletedItemIds(new Set())
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadItems()
+ }, [biddingId])
+
+ // 입찰 정보 및 조건 로드 (사전견적 다이얼로그용)
+ React.useEffect(() => {
+ const loadBiddingInfo = async () => {
+ if (!biddingId) return
+
+ try {
+ const [bidding, conditions] = await Promise.all([
+ getBiddingById(biddingId),
+ getBiddingConditions(biddingId)
+ ])
+
+ if (bidding) {
+ setBiddingPicUserId(bidding.bidPicId || null)
+ setTargetPriceCalculationCriteria(bidding.targetPriceCalculationCriteria || '')
+ }
+
+ if (conditions) {
+ setBiddingConditions(conditions)
+ }
+ } catch (error) {
+ console.error('Failed to load bidding info:', error)
+ }
+ }
+
+ loadBiddingInfo()
+ }, [biddingId])
+
+ const handleSave = async () => {
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+ let hasError = false
+
+ // 모든 아이템을 upsert 처리 (id가 있으면 update, 없으면 insert)
+ for (const item of items) {
+ const targetAmount = calculateTargetAmount(item)
+
+ let result
+ if (item.id > 0) {
+ // 기존 아이템 업데이트
+ result = await updatePrItem(item.id, {
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ quantity: item.quantity ? parseFloat(item.quantity) : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? parseFloat(item.materialWeight) : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : null,
+ targetAmount: targetAmount ? parseFloat(targetAmount) : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount) : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? parseFloat(item.actualAmount) : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate) : null,
+ currency: item.currency || 'KRW',
+ annualUnitPrice: item.annualUnitPrice ? parseFloat(item.annualUnitPrice) : null,
+ prNumber: item.prNumber || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ } as Parameters<typeof updatePrItem>[1], userId)
+ } else {
+ // 새 아이템 추가 (문자열 타입만 허용)
+ result = await addPRItemForBidding(biddingId, {
+ projectId: item.projectId ?? undefined,
+ projectInfo: item.projectInfo ?? null,
+ shi: item.shi ?? null,
+ materialGroupNumber: item.materialGroupNumber ?? null,
+ materialGroupInfo: item.materialGroupInfo ?? null,
+ materialNumber: item.materialNumber ?? null,
+ materialInfo: item.materialInfo ?? null,
+ quantity: item.quantity ?? null,
+ quantityUnit: item.quantityUnit ?? null,
+ totalWeight: item.totalWeight ?? null,
+ weightUnit: item.weightUnit ?? null,
+ priceUnit: item.priceUnit ?? null,
+ purchaseUnit: item.purchaseUnit ?? null,
+ materialWeight: item.materialWeight ?? null,
+ wbsCode: item.wbsCode ?? null,
+ wbsName: item.wbsName ?? null,
+ costCenterCode: item.costCenterCode ?? null,
+ costCenterName: item.costCenterName ?? null,
+ glAccountCode: item.glAccountCode ?? null,
+ glAccountName: item.glAccountName ?? null,
+ targetUnitPrice: item.targetUnitPrice ?? null,
+ targetAmount: targetAmount ?? null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ?? null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ?? null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ requestedDeliveryDate: item.requestedDeliveryDate ?? null,
+ currency: item.currency || 'KRW',
+ annualUnitPrice: item.annualUnitPrice ?? null,
+ prNumber: item.prNumber ?? null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ })
+ }
+
+ if (!result.success) {
+ hasError = true
+ }
+ }
+
+ // 삭제된 아이템들 서버에서 삭제
+ for (const deletedId of deletedItemIds) {
+ const result = await removeBiddingItem(deletedId)
+ if (!result.success) {
+ hasError = true
+ }
+ }
+
+
+ if (hasError) {
+ toast.error('일부 품목 정보 저장에 실패했습니다.')
+ } else {
+ // 내정가 산정 기준 별도 저장 (서버 액션으로 처리)
+ if (targetPriceCalculationCriteria.trim()) {
+ try {
+ const { updateTargetPriceCalculationCriteria } = await import('@/lib/bidding/service')
+ const criteriaResult = await updateTargetPriceCalculationCriteria(biddingId, targetPriceCalculationCriteria.trim(), userId)
+ if (!criteriaResult.success) {
+ console.warn('Failed to save target price calculation criteria:', criteriaResult.error)
+ }
+ } catch (error) {
+ console.error('Failed to save target price calculation criteria:', error)
+ }
+ }
+
+ toast.success('품목 정보가 성공적으로 저장되었습니다.')
+ // 삭제 목록 초기화
+ setDeletedItemIds(new Set())
+ // 데이터 다시 로딩하여 최신 상태 반영
+ const prItems = await getPRItemsForBidding(biddingId)
+ const formattedItems: PRItemInfo[] = prItems.map((item) => ({
+ id: item.id,
+ prNumber: item.prNumber || null,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ quantity: item.quantity ? item.quantity.toString() : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? item.totalWeight.toString() : null,
+ weightUnit: item.weightUnit || null,
+ materialDescription: item.itemInfo || null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate).toISOString().split('T')[0] : null,
+ isRepresentative: false,
+ annualUnitPrice: item.annualUnitPrice ? item.annualUnitPrice.toString() : null,
+ currency: item.currency || 'KRW',
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? item.materialWeight.toString() : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? item.targetUnitPrice.toString() : null,
+ targetAmount: item.targetAmount ? item.targetAmount.toString() : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? item.budgetAmount.toString() : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? item.actualAmount.toString() : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ }))
+
+ // 첫 번째 아이템을 대표로 설정
+ if (formattedItems.length > 0) {
+ formattedItems[0].isRepresentative = true
+ }
+
+ setItems(formattedItems)
+ }
+ } catch (error) {
+ console.error('Failed to save items:', error)
+ toast.error('품목 정보 저장에 실패했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleAddItem = () => {
+ // 임시 ID 생성 (음수로 구분하여 실제 DB ID와 구분)
+ const tempId = -(tempIdCounter + 1)
+ setTempIdCounter(prev => prev + 1)
+
+ // 즉시 UI에 새 아이템 추가 (서버 저장 없음)
+ const newItem: PRItemInfo = {
+ id: tempId, // 임시 ID
+ prNumber: null,
+ projectId: null,
+ projectInfo: null,
+ shi: null,
+ quantity: null,
+ quantityUnit: 'EA',
+ totalWeight: null,
+ weightUnit: 'KG',
+ materialDescription: null,
+ hasSpecDocument: false,
+ requestedDeliveryDate: null,
+ isRepresentative: items.length === 0,
+ annualUnitPrice: null,
+ currency: 'KRW',
+ materialGroupNumber: null,
+ materialGroupInfo: null,
+ materialNumber: null,
+ materialInfo: null,
+ priceUnit: null,
+ purchaseUnit: '1',
+ materialWeight: null,
+ wbsCode: null,
+ wbsName: null,
+ costCenterCode: null,
+ costCenterName: null,
+ glAccountCode: null,
+ glAccountName: null,
+ targetUnitPrice: null,
+ targetAmount: null,
+ targetCurrency: 'KRW',
+ budgetAmount: null,
+ budgetCurrency: 'KRW',
+ actualAmount: null,
+ actualCurrency: 'KRW',
+ }
+
+ setItems((prev) => {
+ // 첫 번째 아이템이면 대표로 설정
+ if (prev.length === 0) {
+ return [newItem]
+ }
+ return [...prev, newItem]
+ })
+ }
+
+ const handleRemoveItem = (itemId: number) => {
+ if (items.length <= 1) {
+ toast.error('최소 하나의 품목이 필요합니다.')
+ return
+ }
+
+ // 실제 아이템인 경우 삭제 목록에 추가 (저장 시 서버에서 삭제됨)
+ if (itemId > 0) {
+ setDeletedItemIds(prev => new Set([...prev, itemId]))
+ }
+
+ // UI에서 즉시 제거
+ setItems((prev) => {
+ const filteredItems = prev.filter((item) => item.id !== itemId)
+ const removedItem = prev.find((item) => item.id === itemId)
+ if (removedItem?.isRepresentative && filteredItems.length > 0) {
+ filteredItems[0].isRepresentative = true
+ }
+ return filteredItems
+ })
+ }
+
+ const updatePRItem = (id: number, updates: Partial<PRItemInfo>) => {
+ setItems((prev) =>
+ prev.map((item) => {
+ if (item.id === id) {
+ const updatedItem = { ...item, ...updates }
+ // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산
+ if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) {
+ updatedItem.targetAmount = calculateTargetAmount(updatedItem)
+ }
+ return updatedItem
+ }
+ return item
+ })
+ )
+ }
+
+ const setRepresentativeItem = (id: number) => {
+ setItems((prev) =>
+ prev.map((item) => ({
+ ...item,
+ isRepresentative: item.id === id,
+ }))
+ )
+ }
+
+ const handleQuantityWeightModeChange = (mode: 'quantity' | 'weight') => {
+ setQuantityWeightMode(mode)
+ }
+
+ const calculateTargetAmount = (item: PRItemInfo) => {
+ const unitPrice = parseFloat(item.targetUnitPrice || '0') || 0
+ const purchaseUnit = parseFloat(item.purchaseUnit || '1') || 1
+ let amount = 0
+
+ if (quantityWeightMode === 'quantity') {
+ const quantity = parseFloat(item.quantity || '0') || 0
+ amount = (quantity / purchaseUnit) * unitPrice
+ } else {
+ const weight = parseFloat(item.totalWeight || '0') || 0
+ amount = (weight / purchaseUnit) * unitPrice
+ }
+
+ return Math.floor(amount).toString()
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">품목 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ // PR 아이템 테이블 렌더링 (create-bidding-dialog와 동일한 구조)
+ const renderPrItemsTable = () => {
+ return (
+ <div className="border rounded-lg overflow-hidden">
+ <div className="overflow-x-auto">
+ <table className="w-full border-collapse">
+ <thead className="bg-muted/50">
+ <tr>
+ <th className="sticky left-0 z-10 bg-muted/50 border-r px-2 py-3 text-left text-xs font-medium min-w-[50px]">
+ <span className="sr-only">대표</span>
+ </th>
+ <th className="sticky left-[50px] z-10 bg-muted/50 border-r px-3 py-3 text-left text-xs font-medium min-w-[40px]">
+ #
+ </th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">예산금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th>
+ <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]">
+ 액션
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {items.map((item, index) => (
+ <tr key={item.id} className="border-t hover:bg-muted/30">
+ <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center">
+ <Checkbox
+ checked={item.isRepresentative}
+ onCheckedChange={() => setRepresentativeItem(item.id)}
+ disabled={items.length <= 1 && item.isRepresentative}
+ title="대표 아이템"
+ />
+ </td>
+ <td className="sticky left-[50px] z-10 bg-background border-r px-3 py-2 text-xs text-muted-foreground">
+ {index + 1}
+ </td>
+ <td className="border-r px-3 py-2">
+ <ProjectSelector
+ selectedProjectId={item.projectId || null}
+ onProjectSelect={(project) => {
+ updatePRItem(item.id, {
+ projectId: project.id,
+ projectInfo: project.projectName
+ })
+ }}
+ placeholder="프로젝트 선택"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="프로젝트명"
+ value={item.projectInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupNumber ? {
+ materialGroupCode: item.materialGroupNumber,
+ materialGroupDescription: item.materialGroupInfo || '',
+ displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo || ''}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialGroupNumber: material.materialGroupCode,
+ materialGroupInfo: material.materialGroupDescription
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재그룹명"
+ value={item.materialGroupInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialSelectorDialogSingle
+ triggerLabel={item.materialNumber || "자재 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialNumber ? {
+ materialCode: item.materialNumber,
+ materialName: item.materialInfo || '',
+ displayText: `${item.materialNumber} - ${item.materialInfo || ''}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialNumber: material.materialCode,
+ materialInfo: material.materialName
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialNumber: '',
+ materialInfo: ''
+ })
+ }
+ }}
+ title="자재 선택"
+ description="자재를 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재명"
+ value={item.materialInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Input
+ type="number"
+ min="0"
+ placeholder="수량"
+ value={item.quantity || ''}
+ onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
+ className="h-8 text-xs"
+ />
+ ) : (
+ <Input
+ type="number"
+ min="0"
+ placeholder="중량"
+ value={item.totalWeight || ''}
+ onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
+ className="h-8 text-xs"
+ />
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Select
+ value={item.quantityUnit || 'EA'}
+ onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <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>
+ ) : (
+ <Select
+ value={item.weightUnit || 'KG'}
+ onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KG">KG</SelectItem>
+ <SelectItem value="TON">TON</SelectItem>
+ <SelectItem value="G">G</SelectItem>
+ <SelectItem value="LB">LB</SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="1"
+ step="1"
+ placeholder="구매단위"
+ value={item.purchaseUnit || ''}
+ onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정단가"
+ value={item.targetUnitPrice || ''}
+ onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정금액"
+ readOnly
+ value={item.targetAmount || ''}
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.targetCurrency || 'KRW'}
+ onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="예산금액"
+ value={item.budgetAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.budgetCurrency || 'KRW'}
+ onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="실적금액"
+ value={item.actualAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.actualCurrency || 'KRW'}
+ onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setSelectedItemForWbs(item.id)
+ setWbsCodeDialogOpen(true)
+ }}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.wbsCode ? (
+ <span className="truncate">
+ {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">WBS 코드 선택</span>
+ )}
+ </Button>
+ <WbsCodeSingleSelector
+ open={wbsCodeDialogOpen && selectedItemForWbs === item.id}
+ onOpenChange={(open) => {
+ setWbsCodeDialogOpen(open)
+ if (!open) setSelectedItemForWbs(null)
+ }}
+ selectedCode={item.wbsCode ? {
+ PROJ_NO: '',
+ WBS_ELMT: item.wbsCode,
+ WBS_ELMT_NM: item.wbsName || '',
+ WBS_LVL: ''
+ } : undefined}
+ onCodeSelect={(wbsCode) => {
+ updatePRItem(item.id, {
+ wbsCode: wbsCode.WBS_ELMT,
+ wbsName: wbsCode.WBS_ELMT_NM
+ })
+ setWbsCodeDialogOpen(false)
+ setSelectedItemForWbs(null)
+ }}
+ title="WBS 코드 선택"
+ description="WBS 코드를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="WBS명"
+ value={item.wbsName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setSelectedItemForCostCenter(item.id)
+ setCostCenterDialogOpen(true)
+ }}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.costCenterCode ? (
+ <span className="truncate">
+ {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">코스트센터 선택</span>
+ )}
+ </Button>
+ <CostCenterSingleSelector
+ open={costCenterDialogOpen && selectedItemForCostCenter === item.id}
+ onOpenChange={(open) => {
+ setCostCenterDialogOpen(open)
+ if (!open) setSelectedItemForCostCenter(null)
+ }}
+ selectedCode={item.costCenterCode ? {
+ KOSTL: item.costCenterCode,
+ KTEXT: '',
+ LTEXT: item.costCenterName || '',
+ DATAB: '',
+ DATBI: ''
+ } : undefined}
+ onCodeSelect={(costCenter) => {
+ updatePRItem(item.id, {
+ costCenterCode: costCenter.KOSTL,
+ costCenterName: costCenter.LTEXT
+ })
+ setCostCenterDialogOpen(false)
+ setSelectedItemForCostCenter(null)
+ }}
+ title="코스트센터 선택"
+ description="코스트센터를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="코스트센터명"
+ value={item.costCenterName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setSelectedItemForGlAccount(item.id)
+ setGlAccountDialogOpen(true)
+ }}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.glAccountCode ? (
+ <span className="truncate">
+ {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">GL계정 선택</span>
+ )}
+ </Button>
+ <GlAccountSingleSelector
+ open={glAccountDialogOpen && selectedItemForGlAccount === item.id}
+ onOpenChange={(open) => {
+ setGlAccountDialogOpen(open)
+ if (!open) setSelectedItemForGlAccount(null)
+ }}
+ selectedCode={item.glAccountCode ? {
+ SAKNR: item.glAccountCode,
+ FIPEX: '',
+ TEXT1: item.glAccountName || ''
+ } : undefined}
+ onCodeSelect={(glAccount) => {
+ updatePRItem(item.id, {
+ glAccountCode: glAccount.SAKNR,
+ glAccountName: glAccount.TEXT1
+ })
+ setGlAccountDialogOpen(false)
+ setSelectedItemForGlAccount(null)
+ }}
+ title="GL 계정 선택"
+ description="GL 계정을 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="GL계정명"
+ value={item.glAccountName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="date"
+ value={item.requestedDeliveryDate || ''}
+ onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="sticky right-0 z-10 bg-background border-l px-3 py-2">
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveItem(item.id)}
+ disabled={items.length <= 1}
+ className="h-7 w-7 p-0"
+ title="품목 삭제"
+ >
+ <Trash2 className="h-3.5 w-3.5" />
+ </Button>
+ </div>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <Card>
+ <CardHeader className="flex flex-row items-center justify-between">
+ <div>
+ <CardTitle className="flex items-center gap-2">
+ <Package className="h-5 w-5" />
+ 입찰 품목 목록
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 입찰 대상 품목들을 관리합니다. 최소 하나의 아이템이 필요하며, 자재그룹코드는 필수입니다
+ </p>
+ <p className="text-xs text-amber-600 mt-1">
+ 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
+ </p>
+ </div>
+ <div className="flex gap-2">
+ <Button onClick={() => setPreQuoteDialogOpen(true)} variant="outline" className="flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ 사전견적
+ </Button>
+ <Button onClick={handleAddItem} className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ 품목 추가
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 내정가 산정 기준 입력 폼 */}
+ <div className="space-y-2">
+ <Label htmlFor="targetPriceCalculationCriteria">내정가 산정 기준 (선택)</Label>
+ <Textarea
+ id="targetPriceCalculationCriteria"
+ placeholder="내정가 산정 기준을 입력하세요"
+ value={targetPriceCalculationCriteria}
+ onChange={(e) => setTargetPriceCalculationCriteria(e.target.value)}
+ rows={3}
+ className="resize-none"
+ />
+ <p className="text-xs text-muted-foreground">
+ 내정가를 산정한 기준이나 방법을 입력하세요
+ </p>
+ </div>
+ <div className="flex items-center space-x-4 p-4 bg-muted rounded-lg">
+ <div className="text-sm font-medium">계산 기준:</div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="quantity-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'quantity'}
+ onChange={() => handleQuantityWeightModeChange('quantity')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="quantity-mode" className="text-sm">수량 기준</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="weight-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'weight'}
+ onChange={() => handleQuantityWeightModeChange('weight')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="weight-mode" className="text-sm">중량 기준</label>
+ </div>
+ </div>
+ <div className="space-y-4">
+ {items.length > 0 ? (
+ renderPrItemsTable()
+ ) : (
+ <div className="text-center py-12 border-2 border-dashed border-gray-300 rounded-lg">
+ <Package 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">
+ 품목을 추가하여 입찰 세부내역을 작성하세요
+ </p>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleAddItem}
+ className="flex items-center gap-2 mx-auto"
+ >
+ <Plus className="h-4 w-4" />
+ 첫 번째 품목 추가
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 액션 버튼 */}
+ <div className="flex justify-end gap-4">
+ <Button
+ onClick={handleSave}
+ disabled={isSubmitting}
+ className="min-w-[120px]"
+ >
+ {isSubmitting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
+
+ {/* 사전견적용 일반견적 생성 다이얼로그 */}
+ <CreatePreQuoteRfqDialog
+ open={preQuoteDialogOpen}
+ onOpenChange={setPreQuoteDialogOpen}
+ biddingId={biddingId}
+ biddingItems={items.map(item => ({
+ id: item.id,
+ materialGroupNumber: item.materialGroupNumber || undefined,
+ materialGroupInfo: item.materialGroupInfo || undefined,
+ materialNumber: item.materialNumber || undefined,
+ materialInfo: item.materialInfo || undefined,
+ quantity: item.quantity || undefined,
+ quantityUnit: item.quantityUnit || undefined,
+ totalWeight: item.totalWeight || undefined,
+ weightUnit: item.weightUnit || undefined,
+ }))}
+ picUserId={biddingPicUserId}
+ biddingConditions={biddingConditions}
+ onSuccess={() => {
+ toast.success('사전견적용 일반견적이 생성되었습니다')
+ }}
+ />
+
+ </div>
+ )
+}
diff --git a/components/bidding/manage/bidding-schedule-editor.tsx b/components/bidding/manage/bidding-schedule-editor.tsx
new file mode 100644
index 00000000..d64c16c0
--- /dev/null
+++ b/components/bidding/manage/bidding-schedule-editor.tsx
@@ -0,0 +1,661 @@
+'use client'
+
+import * as React from 'react'
+import { Calendar, Save, RefreshCw, Clock, Send } from 'lucide-react'
+import { updateBiddingSchedule, getBiddingById, getSpecificationMeetingDetailsAction } from '@/lib/bidding/service'
+import { useSession } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
+
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Switch } from '@/components/ui/switch'
+import { BiddingInvitationDialog } from '@/lib/bidding/detail/table/bidding-invitation-dialog'
+import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from '@/lib/bidding/pre-quote/service'
+import { registerBidding } from '@/lib/bidding/detail/service'
+import { useToast } from '@/hooks/use-toast'
+
+interface BiddingSchedule {
+ submissionStartDate?: string
+ submissionEndDate?: string
+ remarks?: string
+ isUrgent?: boolean
+ hasSpecificationMeeting?: boolean
+}
+
+interface SpecificationMeetingInfo {
+ meetingDate: string
+ meetingTime: string
+ location: string
+ address: string
+ contactPerson: string
+ contactPhone: string
+ contactEmail: string
+ agenda: string
+ materials: string
+ notes: string
+ isRequired: boolean
+}
+
+interface BiddingScheduleEditorProps {
+ biddingId: number
+}
+
+interface VendorContractRequirement {
+ vendorId: number
+ vendorName: string
+ vendorCode?: string | null
+ vendorCountry?: string
+ vendorEmail?: string | null
+ contactPerson?: string | null
+ contactEmail?: string | null
+ ndaYn?: boolean
+ generalGtcYn?: boolean
+ projectGtcYn?: boolean
+ agreementYn?: boolean
+ biddingCompanyId: number
+ biddingId: number
+}
+
+interface VendorWithContactInfo extends VendorContractRequirement {
+ contacts: Array<{
+ id: number
+ contactName: string
+ contactEmail: string
+ contactPhone?: string | null
+ contactPosition?: string | null
+ contactDepartment?: string | null
+ }>
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails: Array<{
+ id: string
+ email: string
+ name?: string
+ }>
+ hasExistingContracts: boolean
+}
+
+interface BiddingInvitationData {
+ vendors: VendorWithContactInfo[]
+ generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }>
+ message?: string
+}
+
+export function BiddingScheduleEditor({ biddingId }: BiddingScheduleEditorProps) {
+ const { data: session } = useSession()
+ const router = useRouter()
+ const { toast } = useToast()
+ const [schedule, setSchedule] = React.useState<BiddingSchedule>({})
+ const [specMeetingInfo, setSpecMeetingInfo] = React.useState<SpecificationMeetingInfo>({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ })
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [biddingInfo, setBiddingInfo] = React.useState<{ title: string; projectName?: string } | null>(null)
+ const [isBiddingInvitationDialogOpen, setIsBiddingInvitationDialogOpen] = React.useState(false)
+ const [selectedVendors, setSelectedVendors] = React.useState<VendorContractRequirement[]>([])
+
+ // 데이터 로딩
+ React.useEffect(() => {
+ const loadSchedule = async () => {
+ setIsLoading(true)
+ try {
+ const bidding = await getBiddingById(biddingId)
+ if (bidding) {
+ // 입찰 정보 저장
+ setBiddingInfo({
+ title: bidding.title || '',
+ projectName: bidding.projectName || undefined,
+ })
+
+ // 날짜를 문자열로 변환하는 헬퍼
+ const formatDateTime = (date: unknown): string => {
+ if (!date) return ''
+ if (typeof date === 'string') {
+ // 이미 datetime-local 형식인 경우
+ if (date.includes('T')) {
+ return date.slice(0, 16)
+ }
+ return date
+ }
+ if (date instanceof Date) return date.toISOString().slice(0, 16)
+ return ''
+ }
+
+ setSchedule({
+ submissionStartDate: formatDateTime(bidding.submissionStartDate),
+ submissionEndDate: formatDateTime(bidding.submissionEndDate),
+ remarks: bidding.remarks || '',
+ isUrgent: bidding.isUrgent || false,
+ hasSpecificationMeeting: bidding.hasSpecificationMeeting || false,
+ })
+
+ // 사양설명회 정보 로드
+ if (bidding.hasSpecificationMeeting) {
+ try {
+ const meetingDetails = await getSpecificationMeetingDetailsAction(biddingId)
+ if (meetingDetails.success && meetingDetails.data) {
+ const meeting = meetingDetails.data
+ setSpecMeetingInfo({
+ meetingDate: meeting.meetingDate ? new Date(meeting.meetingDate).toISOString().slice(0, 16) : '',
+ meetingTime: meeting.meetingTime || '',
+ location: meeting.location || '',
+ address: meeting.address || '',
+ contactPerson: meeting.contactPerson || '',
+ contactPhone: meeting.contactPhone || '',
+ contactEmail: meeting.contactEmail || '',
+ agenda: meeting.agenda || '',
+ materials: meeting.materials || '',
+ notes: meeting.notes || '',
+ isRequired: meeting.isRequired || false,
+ })
+ }
+ } catch (error) {
+ console.error('Failed to load specification meeting details:', error)
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Failed to load schedule:', error)
+ toast({
+ title: '오류',
+ description: '일정 정보를 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadSchedule()
+ }, [biddingId, toast])
+
+ // 선정된 업체들 조회
+ const getSelectedVendors = React.useCallback(async (): Promise<VendorContractRequirement[]> => {
+ try {
+ const result = await getSelectedVendorsForBidding(biddingId)
+ if (result.success) {
+ // 타입 변환: null을 undefined로 변환
+ return result.vendors.map((vendor): VendorContractRequirement => ({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode ?? undefined,
+ vendorCountry: vendor.vendorCountry,
+ vendorEmail: vendor.vendorEmail ?? undefined,
+ contactPerson: vendor.contactPerson ?? undefined,
+ contactEmail: vendor.contactEmail ?? undefined,
+ ndaYn: vendor.ndaYn,
+ generalGtcYn: vendor.generalGtcYn,
+ projectGtcYn: vendor.projectGtcYn,
+ agreementYn: vendor.agreementYn,
+ biddingCompanyId: vendor.biddingCompanyId,
+ biddingId: vendor.biddingId,
+ }))
+ } else {
+ console.error('선정된 업체 조회 실패:', 'error' in result ? result.error : '알 수 없는 오류')
+ return []
+ }
+ } catch (error) {
+ console.error('선정된 업체 조회 실패:', error)
+ return []
+ }
+ }, [biddingId])
+
+ // 본입찰 초대 다이얼로그가 열릴 때 선정된 업체들 조회
+ React.useEffect(() => {
+ if (isBiddingInvitationDialogOpen) {
+ getSelectedVendors().then(vendors => {
+ setSelectedVendors(vendors)
+ })
+ }
+ }, [isBiddingInvitationDialogOpen, getSelectedVendors])
+
+ // 입찰 초대 발송 핸들러
+ const handleBiddingInvitationSend = async (data: BiddingInvitationData) => {
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+
+ // 1. 기본계약 발송
+ // sendBiddingBasicContracts에 필요한 형식으로 변환
+ const vendorDataForContract = data.vendors.map(vendor => ({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ vendorCode: vendor.vendorCode || undefined,
+ vendorCountry: vendor.vendorCountry,
+ selectedMainEmail: vendor.selectedMainEmail,
+ additionalEmails: vendor.additionalEmails,
+ customEmails: vendor.customEmails,
+ contractRequirements: {
+ ndaYn: vendor.ndaYn || false,
+ generalGtcYn: vendor.generalGtcYn || false,
+ projectGtcYn: vendor.projectGtcYn || false,
+ agreementYn: vendor.agreementYn || false,
+ },
+ biddingCompanyId: vendor.biddingCompanyId,
+ biddingId: vendor.biddingId,
+ hasExistingContracts: vendor.hasExistingContracts,
+ }))
+
+ const contractResult = await sendBiddingBasicContracts(
+ biddingId,
+ vendorDataForContract,
+ data.generatedPdfs,
+ data.message
+ )
+
+ if (!contractResult.success) {
+ const errorMessage = 'message' in contractResult
+ ? contractResult.message
+ : 'error' in contractResult
+ ? contractResult.error
+ : '기본계약 발송에 실패했습니다.'
+ toast({
+ title: '기본계약 발송 실패',
+ description: errorMessage,
+ variant: 'destructive',
+ })
+ return
+ }
+
+ // 2. 입찰 등록 진행
+ const registerResult = await registerBidding(biddingId, userId)
+
+ if (registerResult.success) {
+ toast({
+ title: '본입찰 초대 완료',
+ description: '기본계약 발송 및 본입찰 초대가 완료되었습니다.',
+ })
+ setIsBiddingInvitationDialogOpen(false)
+ router.refresh()
+ } else {
+ toast({
+ title: '오류',
+ description: 'error' in registerResult ? registerResult.error : '입찰 등록에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('본입찰 초대 실패:', error)
+ toast({
+ title: '오류',
+ description: '본입찰 초대에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ }
+
+ const handleSave = async () => {
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+
+ // 사양설명회 정보 유효성 검사
+ if (schedule.hasSpecificationMeeting) {
+ if (!specMeetingInfo.meetingDate || !specMeetingInfo.location || !specMeetingInfo.contactPerson) {
+ toast({
+ title: '오류',
+ description: '사양설명회 필수 정보가 누락되었습니다. (회의일시, 장소, 담당자)',
+ variant: 'destructive',
+ })
+ setIsSubmitting(false)
+ return
+ }
+ }
+
+ const result = await updateBiddingSchedule(
+ biddingId,
+ schedule,
+ userId,
+ schedule.hasSpecificationMeeting ? specMeetingInfo : undefined
+ )
+
+ if (result.success) {
+ toast({
+ title: '성공',
+ description: '일정 정보가 성공적으로 저장되었습니다.',
+ })
+ } else {
+ toast({
+ title: '오류',
+ description: 'error' in result ? result.error : '일정 정보 저장에 실패했습니다.',
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('Failed to save schedule:', error)
+ toast({
+ title: '오류',
+ description: '일정 정보 저장에 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleScheduleChange = (field: keyof BiddingSchedule, value: string | boolean) => {
+ setSchedule(prev => ({ ...prev, [field]: value }))
+
+ // 사양설명회 실시 여부가 false로 변경되면 상세 정보 초기화
+ if (field === 'hasSpecificationMeeting' && value === false) {
+ setSpecMeetingInfo({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ })
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center p-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">일정 정보를 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Calendar className="h-5 w-5" />
+ 입찰 일정 관리
+ </CardTitle>
+ <p className="text-sm text-muted-foreground mt-1">
+ 입찰의 주요 일정들을 설정하고 관리합니다.
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 입찰서 제출 기간 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium flex items-center gap-2">
+ <Clock className="h-4 w-4" />
+ 입찰서 제출 기간
+ </h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="submission-start">제출 시작일시</Label>
+ <Input
+ id="submission-start"
+ type="datetime-local"
+ value={schedule.submissionStartDate || ''}
+ onChange={(e) => handleScheduleChange('submissionStartDate', e.target.value)}
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="submission-end">제출 마감일시</Label>
+ <Input
+ id="submission-end"
+ type="datetime-local"
+ value={schedule.submissionEndDate || ''}
+ onChange={(e) => handleScheduleChange('submissionEndDate', e.target.value)}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 긴급 여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">긴급여부</Label>
+ <p className="text-sm text-muted-foreground">
+ 긴급 입찰로 표시할 경우 활성화하세요
+ </p>
+ </div>
+ <Switch
+ checked={schedule.isUrgent || false}
+ onCheckedChange={(checked) => handleScheduleChange('isUrgent', checked)}
+ />
+ </div>
+
+ {/* 사양설명회 실시 여부 */}
+ <div className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <Label className="text-base">사양설명회 실시</Label>
+ <p className="text-sm text-muted-foreground">
+ 사양설명회를 실시할 경우 상세 정보를 입력하세요
+ </p>
+ </div>
+ <Switch
+ checked={schedule.hasSpecificationMeeting || false}
+ onCheckedChange={(checked) => handleScheduleChange('hasSpecificationMeeting', checked)}
+ />
+ </div>
+
+ {/* 사양설명회 상세 정보 */}
+ {schedule.hasSpecificationMeeting && (
+ <div className="space-y-6 p-4 border rounded-lg bg-muted/50">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label>회의일시 <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>회의시간</Label>
+ <Input
+ placeholder="예: 14:00 ~ 16:00"
+ value={specMeetingInfo.meetingTime}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))}
+ />
+ </div>
+ </div>
+ <div>
+ <Label>장소 <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>주소</Label>
+ <Input
+ placeholder="회의 장소 주소"
+ value={specMeetingInfo.address}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, address: e.target.value }))}
+ />
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <div>
+ <Label>담당자 <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>연락처</Label>
+ <Input
+ placeholder="전화번호"
+ value={specMeetingInfo.contactPhone}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))}
+ />
+ </div>
+ <div>
+ <Label>이메일</Label>
+ <Input
+ type="email"
+ placeholder="이메일"
+ value={specMeetingInfo.contactEmail}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))}
+ />
+ </div>
+ </div>
+ <div>
+ <Label>안건</Label>
+ <Textarea
+ placeholder="회의 안건을 입력하세요"
+ value={specMeetingInfo.agenda}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, agenda: e.target.value }))}
+ rows={3}
+ />
+ </div>
+ <div>
+ <Label>자료</Label>
+ <Textarea
+ placeholder="회의 자료 정보를 입력하세요"
+ value={specMeetingInfo.materials}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, materials: e.target.value }))}
+ rows={3}
+ />
+ </div>
+ <div>
+ <Label>비고</Label>
+ <Textarea
+ placeholder="추가 사항을 입력하세요"
+ value={specMeetingInfo.notes}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, notes: e.target.value }))}
+ rows={3}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 비고 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">비고</h3>
+ <div className="space-y-2">
+ <Label htmlFor="remarks">추가 사항</Label>
+ <Textarea
+ id="remarks"
+ value={schedule.remarks || ''}
+ onChange={(e) => handleScheduleChange('remarks', e.target.value)}
+ placeholder="일정에 대한 추가 설명이나 참고사항을 입력하세요"
+ rows={4}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 일정 요약 카드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>일정 요약</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2 text-sm">
+ <div className="flex justify-between">
+ <span className="font-medium">입찰서 제출 기간:</span>
+ <span>
+ {schedule.submissionStartDate && schedule.submissionEndDate
+ ? `${new Date(schedule.submissionStartDate).toLocaleString('ko-KR')} ~ ${new Date(schedule.submissionEndDate).toLocaleString('ko-KR')}`
+ : '미설정'
+ }
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="font-medium">긴급여부:</span>
+ <span>
+ {schedule.isUrgent ? '예' : '아니오'}
+ </span>
+ </div>
+ <div className="flex justify-between">
+ <span className="font-medium">사양설명회 실시:</span>
+ <span>
+ {schedule.hasSpecificationMeeting ? '예' : '아니오'}
+ </span>
+ </div>
+ {schedule.hasSpecificationMeeting && specMeetingInfo.meetingDate && (
+ <div className="flex justify-between">
+ <span className="font-medium">사양설명회 일시:</span>
+ <span>
+ {new Date(specMeetingInfo.meetingDate).toLocaleString('ko-KR')}
+ </span>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 액션 버튼 */}
+ <div className="flex justify-between gap-4">
+ <Button
+ variant="default"
+ onClick={() => setIsBiddingInvitationDialogOpen(true)}
+ disabled={!biddingInfo}
+ className="min-w-[120px]"
+ >
+ <Send className="w-4 h-4 mr-2" />
+ 입찰공고
+ </Button>
+ <div className="flex gap-4">
+ <Button
+ onClick={handleSave}
+ disabled={isSubmitting}
+ className="min-w-[120px]"
+ >
+ {isSubmitting ? (
+ <>
+ <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+
+ {/* 입찰 초대 다이얼로그 */}
+ {biddingInfo && (
+ <BiddingInvitationDialog
+ open={isBiddingInvitationDialogOpen}
+ onOpenChange={setIsBiddingInvitationDialogOpen}
+ vendors={selectedVendors}
+ biddingId={biddingId}
+ biddingTitle={biddingInfo.title}
+ onSend={handleBiddingInvitationSend}
+ />
+ )}
+ </div>
+ )
+}
+
diff --git a/components/bidding/manage/create-pre-quote-rfq-dialog.tsx b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
new file mode 100644
index 00000000..88732deb
--- /dev/null
+++ b/components/bidding/manage/create-pre-quote-rfq-dialog.tsx
@@ -0,0 +1,742 @@
+"use client"
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { format } from "date-fns"
+import { CalendarIcon, Loader2, Trash2, PlusCircle } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ 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 {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Calendar } from "@/components/ui/calendar"
+import { Badge } from "@/components/ui/badge"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { createPreQuoteRfqAction, previewGeneralRfqCode } from "@/lib/bidding/service"
+import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
+import { MaterialSearchItem } from "@/lib/material/material-group-service"
+import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
+import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
+import { ProcurementManagerSelector } from "@/components/common/selectors/procurement-manager"
+import type { ProcurementManagerWithUser } from "@/components/common/selectors/procurement-manager/procurement-manager-service"
+
+// 아이템 스키마
+const itemSchema = z.object({
+ itemCode: z.string().optional(),
+ itemName: z.string().optional(),
+ materialCode: z.string().optional(),
+ materialName: z.string().optional(),
+ quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
+ uom: z.string().min(1, "단위를 입력해주세요"),
+ remark: z.string().optional(),
+})
+
+// 사전견적용 일반견적 생성 폼 스키마
+const createPreQuoteRfqSchema = z.object({
+ rfqType: z.string().min(1, "견적 종류를 선택해주세요"),
+ rfqTitle: z.string().min(1, "견적명을 입력해주세요"),
+ dueDate: z.date({
+ required_error: "제출마감일을 선택해주세요",
+ }),
+ picUserId: z.number().optional(),
+ projectId: z.number().optional(),
+ remark: z.string().optional(),
+ items: z.array(itemSchema).min(1, "최소 하나의 자재를 추가해주세요"),
+})
+
+type CreatePreQuoteRfqFormValues = z.infer<typeof createPreQuoteRfqSchema>
+
+interface CreatePreQuoteRfqDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ biddingId: number
+ biddingItems: Array<{
+ id: number
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ materialNumber?: string | null
+ materialInfo?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ }>
+ biddingConditions?: {
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ } | null
+ onSuccess?: () => void
+}
+
+export function CreatePreQuoteRfqDialog({
+ open,
+ onOpenChange,
+ biddingId,
+ biddingItems,
+ biddingConditions,
+ onSuccess
+}: CreatePreQuoteRfqDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [previewCode, setPreviewCode] = React.useState("")
+ const [isLoadingPreview, setIsLoadingPreview] = React.useState(false)
+ const [selectedManager, setSelectedManager] = React.useState<ProcurementManagerWithUser | undefined>(undefined)
+ const { data: session } = useSession()
+
+ const userId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null;
+ }, [session]);
+
+ // 입찰품목을 일반견적 아이템으로 매핑
+ const initialItems = React.useMemo(() => {
+ return biddingItems.map((item) => ({
+ itemCode: item.materialGroupNumber || "",
+ itemName: item.materialGroupInfo || "",
+ materialCode: item.materialNumber || "",
+ materialName: item.materialInfo || "",
+ quantity: item.quantity ? parseFloat(item.quantity) : 1,
+ uom: item.quantityUnit || item.weightUnit || "EA",
+ remark: "",
+ }))
+ }, [biddingItems])
+
+ const form = useForm<CreatePreQuoteRfqFormValues>({
+ resolver: zodResolver(createPreQuoteRfqSchema),
+ defaultValues: {
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ })
+
+ // 다이얼로그가 열릴 때 폼 초기화
+ React.useEffect(() => {
+ if (open) {
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedManager(undefined)
+ setPreviewCode("")
+ }
+ }, [open, initialItems, form])
+
+ // 견적담당자 선택 시 RFQ 코드 미리보기 생성
+ React.useEffect(() => {
+ if (!selectedManager?.user?.id) {
+ setPreviewCode("")
+ return
+ }
+
+ // 즉시 실행 함수 패턴 사용
+ (async () => {
+ setIsLoadingPreview(true)
+ try {
+ const code = await previewGeneralRfqCode(selectedManager.user!.id!)
+ setPreviewCode(code)
+ } catch (error) {
+ console.error("코드 미리보기 오류:", error)
+ setPreviewCode("")
+ } finally {
+ setIsLoadingPreview(false)
+ }
+ })()
+ }, [selectedManager])
+
+ // 견적 종류 변경
+ const handleRfqTypeChange = (value: string) => {
+ form.setValue("rfqType", value)
+ }
+
+ const handleCancel = () => {
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: undefined,
+ projectId: undefined,
+ remark: "",
+ items: initialItems.length > 0 ? initialItems : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedManager(undefined)
+ setPreviewCode("")
+ onOpenChange(false)
+ }
+
+ const onSubmit = async (data: CreatePreQuoteRfqFormValues) => {
+ if (!userId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ if (!selectedManager?.user?.id) {
+ toast.error("견적담당자를 선택해주세요")
+ return
+ }
+
+ const picUserId = selectedManager.user.id
+
+ setIsLoading(true)
+
+ try {
+ // 서버 액션 호출 (입찰 조건 포함)
+ const result = await createPreQuoteRfqAction({
+ biddingId,
+ rfqType: data.rfqType,
+ rfqTitle: data.rfqTitle,
+ dueDate: data.dueDate,
+ picUserId,
+ projectId: data.projectId,
+ remark: data.remark || "",
+ items: data.items as Array<{
+ itemCode: string;
+ itemName: string;
+ materialCode?: string;
+ materialName?: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ }>,
+ biddingConditions: biddingConditions || undefined,
+ createdBy: userId,
+ updatedBy: userId,
+ })
+
+ if (result.success) {
+ toast.success(result.message, {
+ description: result.data?.rfqCode ? `RFQ 코드: ${result.data.rfqCode}` : undefined,
+ })
+
+ // 다이얼로그 닫기
+ onOpenChange(false)
+
+ // 성공 콜백 실행
+ if (onSuccess) {
+ onSuccess()
+ }
+ } else {
+ toast.error(result.error || "사전견적용 일반견적 생성에 실패했습니다")
+ }
+
+ } catch (error) {
+ console.error('사전견적용 일반견적 생성 오류:', error)
+ toast.error("사전견적용 일반견적 생성에 실패했습니다", {
+ description: "알 수 없는 오류가 발생했습니다",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 아이템 추가
+ const handleAddItem = () => {
+ append({
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col">
+ {/* 고정된 헤더 */}
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>사전견적용 일반견적 생성</DialogTitle>
+ <DialogDescription>
+ 입찰의 사전견적을 위한 일반견적을 생성합니다. 입찰품목이 자재정보로 매핑되어 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <ScrollArea className="flex-1 px-1">
+ <Form {...form}>
+ <form id="createPreQuoteRfqForm" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2">
+
+ {/* 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ {/* 견적 종류 */}
+ <div className="space-y-2">
+ <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적 종류 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={handleRfqTypeChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="견적 종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="단가계약">단가계약</SelectItem>
+ <SelectItem value="매각계약">매각계약</SelectItem>
+ <SelectItem value="일반계약">일반계약</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 제출마감일 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 제출마감일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy-MM-dd")
+ ) : (
+ <span>제출마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 견적명 */}
+ <FormField
+ control={form.control}
+ name="rfqTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 견적명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 입찰 사전견적용 일반견적"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 견적의 목적이나 내용을 간단명료하게 입력해주세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>프로젝트</FormLabel>
+ <FormControl>
+ {/* ProjectSelector는 별도 컴포넌트 필요 */}
+ <Input
+ placeholder="프로젝트 ID (선택사항)"
+ type="number"
+ {...field}
+ onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 담당자 정보 */}
+ <FormField
+ control={form.control}
+ name="picUserId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적담당자 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <ProcurementManagerSelector
+ selectedManager={selectedManager}
+ onManagerSelect={(manager) => {
+ setSelectedManager(manager)
+ field.onChange(manager.user?.id)
+ }}
+ placeholder="견적담당자를 선택하세요"
+ />
+ </FormControl>
+ <FormDescription>
+ 사전견적용 일반견적의 담당자를 선택합니다
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* RFQ 코드 미리보기 */}
+ {previewCode && (
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary" className="font-mono text-sm">
+ 예상 RFQ 코드: {previewCode}
+ </Badge>
+ {isLoadingPreview && (
+ <Loader2 className="h-3 w-3 animate-spin" />
+ )}
+ </div>
+ )}
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 비고사항을 입력하세요"
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 아이템 정보 섹션 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">자재 정보</h3>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleAddItem}
+ >
+ <PlusCircle className="mr-2 h-4 w-4" />
+ 자재 추가
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ {fields.map((field, index) => (
+ <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50">
+ <div className="flex items-center justify-between mb-3">
+ <span className="text-sm font-medium text-gray-700">
+ 자재 #{index + 1}
+ </span>
+ {fields.length > 1 && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => remove(index)}
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+
+ {/* 자재그룹 선택 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재그룹(자재그룹명) <span className="text-red-500">*</span>
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel="자재그룹 선택"
+ selectedMaterial={(() => {
+ const itemCode = form.watch(`items.${index}.itemCode`);
+ const itemName = form.watch(`items.${index}.itemName`);
+ if (itemCode && itemName) {
+ return {
+ materialGroupCode: itemCode,
+ materialGroupDescription: itemName,
+ displayText: `${itemCode} - ${itemName}`
+ } as MaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.itemCode`, material?.materialGroupCode || '');
+ form.setValue(`items.${index}.itemName`, material?.materialGroupDescription || '');
+ }}
+ placeholder="자재그룹을 검색하세요..."
+ title="자재그룹 선택"
+ description="원하는 자재그룹을 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ {/* 자재코드 선택 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재코드(자재명)
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialSelectorDialogSingle
+ triggerLabel="자재코드 선택"
+ selectedMaterial={(() => {
+ const materialCode = form.watch(`items.${index}.materialCode`);
+ const materialName = form.watch(`items.${index}.materialName`);
+ if (materialCode && materialName) {
+ return {
+ materialCode: materialCode,
+ materialName: materialName,
+ displayText: `${materialCode} - ${materialName}`
+ } as SAPMaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.materialCode`, material?.materialCode || '');
+ form.setValue(`items.${index}.materialName`, material?.materialName || '');
+ }}
+ placeholder="자재코드를 검색하세요..."
+ title="자재코드 선택"
+ description="원하는 자재코드를 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3">
+ {/* 수량 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 수량 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ placeholder="1"
+ className="h-8 text-sm"
+ {...field}
+ onChange={(e) => field.onChange(Number(e.target.value))}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단위 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.uom`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 단위 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger className="h-8 text-sm">
+ <SelectValue placeholder="단위 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="EA">EA (Each)</SelectItem>
+ <SelectItem value="KG">KG (Kilogram)</SelectItem>
+ <SelectItem value="M">M (Meter)</SelectItem>
+ <SelectItem value="L">L (Liter)</SelectItem>
+ <SelectItem value="PC">PC (Piece)</SelectItem>
+ <SelectItem value="BOX">BOX (Box)</SelectItem>
+ <SelectItem value="SET">SET (Set)</SelectItem>
+ <SelectItem value="LOT">LOT (Lot)</SelectItem>
+ <SelectItem value="PCS">PCS (Pieces)</SelectItem>
+ <SelectItem value="TON">TON (Ton)</SelectItem>
+ <SelectItem value="G">G (Gram)</SelectItem>
+ <SelectItem value="ML">ML (Milliliter)</SelectItem>
+ <SelectItem value="CM">CM (Centimeter)</SelectItem>
+ <SelectItem value="MM">MM (Millimeter)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 비고 */}
+ <div className="mt-3">
+ <FormField
+ control={form.control}
+ name={`items.${index}.remark`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">비고</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="자재별 비고사항"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </form>
+ </Form>
+ </ScrollArea>
+
+ {/* 고정된 푸터 */}
+ <DialogFooter className="flex-shrink-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="createPreQuoteRfqForm"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "생성 중..." : "사전견적용 일반견적 생성"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/bidding/price-adjustment-dialog.tsx b/components/bidding/price-adjustment-dialog.tsx
index 982d8b90..149b8e9a 100644
--- a/components/bidding/price-adjustment-dialog.tsx
+++ b/components/bidding/price-adjustment-dialog.tsx
@@ -127,11 +127,11 @@ export function PriceAdjustmentDialog({
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs text-gray-500">기준시점</label>
- <p className="text-sm font-medium">{formatDate(data.referenceDate, "kr")}</p>
+ <p className="text-sm font-medium">{data.referenceDate ? formatDate(data.referenceDate, "kr") : '-'}</p>
</div>
<div>
<label className="text-xs text-gray-500">비교시점</label>
- <p className="text-sm font-medium">{formatDate(data.comparisonDate, "kr")}</p>
+ <p className="text-sm font-medium">{data.comparisonDate ? formatDate(data.comparisonDate, "kr") : '-'}</p>
</div>
</div>
<div>
@@ -162,7 +162,7 @@ export function PriceAdjustmentDialog({
</div>
<div>
<label className="text-xs text-gray-500">조정일</label>
- <p className="text-sm font-medium">{formatDate(data.adjustmentDate, "kr")}</p>
+ <p className="text-sm font-medium">{data.adjustmentDate ? formatDate(data.adjustmentDate, "kr") : '-'}</p>
</div>
</div>
<div>
diff --git a/components/common/selectors/cost-center/cost-center-selector.tsx b/components/common/selectors/cost-center/cost-center-selector.tsx
new file mode 100644
index 00000000..32c37973
--- /dev/null
+++ b/components/common/selectors/cost-center/cost-center-selector.tsx
@@ -0,0 +1,335 @@
+'use client'
+
+/**
+ * Cost Center 선택기
+ *
+ * @description
+ * - 오라클에서 Cost Center들을 조회
+ * - KOSTL: Cost Center 코드
+ * - KTEXT: 단축명
+ * - LTEXT: 설명
+ * - DATAB: 시작일
+ * - DATBI: 종료일
+ */
+
+import { useState, useCallback, useMemo, useTransition } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getCostCenters,
+ CostCenter
+} from './cost-center-service'
+import { toast } from 'sonner'
+
+export interface CostCenterSelectorProps {
+ selectedCode?: CostCenter
+ onCodeSelect: (code: CostCenter) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+}
+
+export interface CostCenterItem {
+ kostl: string // Cost Center
+ ktext: string // 단축명
+ ltext: string // 설명
+ datab: string // 시작일
+ datbi: string // 종료일
+ displayText: string // 표시용 텍스트
+}
+
+export function CostCenterSelector({
+ selectedCode,
+ onCodeSelect,
+ disabled,
+ placeholder = "코스트센터를 선택하세요",
+ className
+}: CostCenterSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [codes, setCodes] = useState<CostCenter[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+
+ // 날짜 포맷 함수 (YYYYMMDD -> YYYY-MM-DD)
+ const formatDate = (dateStr: string) => {
+ if (!dateStr || dateStr.length !== 8) return dateStr
+ return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`
+ }
+
+ // Cost Center 선택 핸들러
+ const handleCodeSelect = useCallback(async (code: CostCenter) => {
+ onCodeSelect(code)
+ setOpen(false)
+ }, [onCodeSelect])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<CostCenter>[] = useMemo(() => [
+ {
+ accessorKey: 'KOSTL',
+ header: '코스트센터',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('KOSTL')}</div>
+ ),
+ },
+ {
+ accessorKey: 'KTEXT',
+ header: '단축명',
+ cell: ({ row }) => (
+ <div>{row.getValue('KTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'LTEXT',
+ header: '설명',
+ cell: ({ row }) => (
+ <div>{row.getValue('LTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATAB',
+ header: '시작일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATAB'))}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATBI',
+ header: '종료일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATBI'))}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ], [handleCodeSelect])
+
+ // Cost Center 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 Cost Center 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getCostCenters()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || '코스트센터를 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('코스트센터 목록 로드 실패:', error)
+ toast.error('코스트센터를 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열기 핸들러
+ const handleDialogOpenChange = useCallback((newOpen: boolean) => {
+ setOpen(newOpen)
+ if (newOpen && codes.length === 0) {
+ loadCodes()
+ }
+ }, [loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ disabled={disabled}
+ className={`w-full justify-start ${className || ''}`}
+ >
+ {selectedCode ? (
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-mono text-sm">[{selectedCode.KOSTL}]</span>
+ <span className="truncate flex-1 text-left">{selectedCode.KTEXT}</span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>코스트센터 선택</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ 코스트센터 조회
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="코스트센터 코드, 단축명, 설명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">코스트센터를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 코스트센터
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/cost-center/cost-center-service.ts b/components/common/selectors/cost-center/cost-center-service.ts
new file mode 100644
index 00000000..844215f0
--- /dev/null
+++ b/components/common/selectors/cost-center/cost-center-service.ts
@@ -0,0 +1,89 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// Cost Center 타입 정의
+export interface CostCenter {
+ KOSTL: string // Cost Center 코드
+ DATAB: string // 시작일
+ DATBI: string // 종료일
+ KTEXT: string // 단축 텍스트
+ LTEXT: string // 긴 텍스트
+}
+
+// 테스트 환경용 폴백 데이터
+const FALLBACK_TEST_DATA: CostCenter[] = [
+ { KOSTL: 'D6023930', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매팀', LTEXT: '구매팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023931', DATAB: '20230101', DATBI: '99991231', KTEXT: '자재팀', LTEXT: '자재팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023932', DATAB: '20230101', DATBI: '99991231', KTEXT: '조달팀', LTEXT: '조달팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023933', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매1팀', LTEXT: '구매1팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+ { KOSTL: 'D6023934', DATAB: '20230101', DATBI: '99991231', KTEXT: '구매2팀', LTEXT: '구매2팀 Cost Center(테스트데이터 - 오라클 페칭 실패시)' },
+]
+
+/**
+ * Cost Center 목록 조회 (Oracle에서 전체 조회, 실패 시 폴백 데이터 사용)
+ * CMCTB_COSTCENTER 테이블에서 조회
+ * 현재 유효한(SYSDATE BETWEEN DATAB AND DATBI) Cost Center만 조회
+ */
+export async function getCostCenters(): Promise<{
+ success: boolean
+ data: CostCenter[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getCostCenters] Oracle 쿼리 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT
+ KOSTL,
+ DATAB,
+ DATBI,
+ KTEXT,
+ LTEXT
+ FROM CMCTB_COSTCENTER
+ WHERE ROWNUM < 100
+ AND NVL(BKZKP,' ') = ' '
+ AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN DATAB AND DATBI
+ AND KOKRS = 'H100'
+ ORDER BY KOSTL
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getCostCenters] Oracle 쿼리 성공 - ${rows.length}건 조회`)
+
+ // null 값 필터링
+ const cleanedResult = rows
+ .filter((item) =>
+ item.KOSTL &&
+ item.DATAB &&
+ item.DATBI
+ )
+ .map((item) => ({
+ KOSTL: String(item.KOSTL),
+ DATAB: String(item.DATAB),
+ DATBI: String(item.DATBI),
+ KTEXT: String(item.KTEXT || ''),
+ LTEXT: String(item.LTEXT || '')
+ }))
+
+ console.log(`✅ [getCostCenters] 필터링 후 ${cleanedResult.length}건`)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getCostCenters] Oracle 오류:', error)
+ console.log('🔄 [getCostCenters] 폴백 테스트 데이터 사용 (' + FALLBACK_TEST_DATA.length + '건)')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
diff --git a/components/common/selectors/cost-center/cost-center-single-selector.tsx b/components/common/selectors/cost-center/cost-center-single-selector.tsx
new file mode 100644
index 00000000..94d9a730
--- /dev/null
+++ b/components/common/selectors/cost-center/cost-center-single-selector.tsx
@@ -0,0 +1,378 @@
+'use client'
+
+/**
+ * Cost Center 단일 선택 다이얼로그
+ *
+ * @description
+ * - Cost Center를 하나만 선택할 수 있는 다이얼로그
+ * - 트리거 버튼과 다이얼로그가 분리된 구조
+ * - 외부에서 open 상태를 제어 가능
+ */
+
+import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check, X } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getCostCenters,
+ CostCenter
+} from './cost-center-service'
+import { toast } from 'sonner'
+
+export interface CostCenterSingleSelectorProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedCode?: CostCenter
+ onCodeSelect: (code: CostCenter) => void
+ onConfirm?: (code: CostCenter | undefined) => void
+ onCancel?: () => void
+ title?: string
+ description?: string
+ showConfirmButtons?: boolean
+}
+
+export function CostCenterSingleSelector({
+ open,
+ onOpenChange,
+ selectedCode,
+ onCodeSelect,
+ onConfirm,
+ onCancel,
+ title = "코스트센터 선택",
+ description = "코스트센터를 선택하세요",
+ showConfirmButtons = false
+}: CostCenterSingleSelectorProps) {
+ const [codes, setCodes] = useState<CostCenter[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+ const [tempSelectedCode, setTempSelectedCode] = useState<CostCenter | undefined>(selectedCode)
+
+ // 날짜 포맷 함수 (YYYYMMDD -> YYYY-MM-DD)
+ const formatDate = (dateStr: string) => {
+ if (!dateStr || dateStr.length !== 8) return dateStr
+ return `${dateStr.substring(0, 4)}-${dateStr.substring(4, 6)}-${dateStr.substring(6, 8)}`
+ }
+
+ // Cost Center 선택 핸들러
+ const handleCodeSelect = useCallback((code: CostCenter) => {
+ if (showConfirmButtons) {
+ setTempSelectedCode(code)
+ } else {
+ onCodeSelect(code)
+ onOpenChange(false)
+ }
+ }, [onCodeSelect, onOpenChange, showConfirmButtons])
+
+ // 확인 버튼 핸들러
+ const handleConfirm = useCallback(() => {
+ if (tempSelectedCode) {
+ onCodeSelect(tempSelectedCode)
+ }
+ onConfirm?.(tempSelectedCode)
+ onOpenChange(false)
+ }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
+
+ // 취소 버튼 핸들러
+ const handleCancel = useCallback(() => {
+ setTempSelectedCode(selectedCode)
+ onCancel?.()
+ onOpenChange(false)
+ }, [selectedCode, onCancel, onOpenChange])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<CostCenter>[] = useMemo(() => [
+ {
+ accessorKey: 'KOSTL',
+ header: '코스트센터',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('KOSTL')}</div>
+ ),
+ },
+ {
+ accessorKey: 'KTEXT',
+ header: '단축명',
+ cell: ({ row }) => (
+ <div>{row.getValue('KTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'LTEXT',
+ header: '설명',
+ cell: ({ row }) => (
+ <div>{row.getValue('LTEXT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATAB',
+ header: '시작일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATAB'))}</div>
+ ),
+ },
+ {
+ accessorKey: 'DATBI',
+ header: '종료일',
+ cell: ({ row }) => (
+ <div className="text-sm">{formatDate(row.getValue('DATBI'))}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => {
+ const isSelected = showConfirmButtons
+ ? tempSelectedCode?.KOSTL === row.original.KOSTL
+ : selectedCode?.KOSTL === row.original.KOSTL
+
+ return (
+ <Button
+ variant={isSelected ? "default" : "ghost"}
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ )
+ },
+ },
+ ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
+
+ // Cost Center 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 Cost Center 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getCostCenters()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || '코스트센터를 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('코스트센터 목록 로드 실패:', error)
+ toast.error('코스트센터를 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
+ useEffect(() => {
+ if (open) {
+ setTempSelectedCode(selectedCode)
+ if (codes.length === 0) {
+ console.log('🚀 [CostCenterSingleSelector] 다이얼로그 열림 - loadCodes 호출')
+ loadCodes()
+ } else {
+ console.log('📦 [CostCenterSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
+ }
+ }
+ }, [open, selectedCode, loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {description}
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 현재 선택된 코스트센터 표시 */}
+ {currentSelectedCode && (
+ <div className="p-3 bg-muted rounded-md">
+ <div className="text-sm font-medium">선택된 코스트센터:</div>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="font-mono text-sm">[{currentSelectedCode.KOSTL}]</span>
+ <span>{currentSelectedCode.KTEXT}</span>
+ <span className="text-muted-foreground">- {currentSelectedCode.LTEXT}</span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="코스트센터 코드, 단축명, 설명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">코스트센터를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isRowSelected = currentSelectedCode?.KOSTL === row.original.KOSTL
+ return (
+ <TableRow
+ key={row.id}
+ data-state={isRowSelected && "selected"}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isRowSelected ? 'bg-muted' : ''
+ }`}
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 코스트센터
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ <X className="h-4 w-4 mr-2" />
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
+ <Check className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/cost-center/index.ts b/components/common/selectors/cost-center/index.ts
new file mode 100644
index 00000000..891e2e6c
--- /dev/null
+++ b/components/common/selectors/cost-center/index.ts
@@ -0,0 +1,12 @@
+// Cost Center 선택기 관련 컴포넌트와 타입 내보내기
+
+export { CostCenterSelector, CostCenterSingleSelector } from './cost-center-selector'
+export type { CostCenterSelectorProps, CostCenterSingleSelectorProps, CostCenterItem } from './cost-center-selector'
+
+export {
+ getCostCenters
+} from './cost-center-service'
+export type {
+ CostCenter
+} from './cost-center-service'
+
diff --git a/components/common/selectors/gl-account/gl-account-selector.tsx b/components/common/selectors/gl-account/gl-account-selector.tsx
new file mode 100644
index 00000000..81a33944
--- /dev/null
+++ b/components/common/selectors/gl-account/gl-account-selector.tsx
@@ -0,0 +1,311 @@
+'use client'
+
+/**
+ * GL 계정 선택기
+ *
+ * @description
+ * - 오라클에서 GL 계정들을 조회
+ * - SAKNR: 계정(G/L)
+ * - FIPEX: 세부계정
+ * - TEXT1: 계정명
+ */
+
+import { useState, useCallback, useMemo, useTransition } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getGlAccounts,
+ GlAccount
+} from './gl-account-service'
+import { toast } from 'sonner'
+
+export interface GlAccountSelectorProps {
+ selectedCode?: GlAccount
+ onCodeSelect: (code: GlAccount) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+}
+
+export interface GlAccountItem {
+ saknr: string // 계정(G/L)
+ fipex: string // 세부계정
+ text1: string // 계정명
+ displayText: string // 표시용 텍스트
+}
+
+export function GlAccountSelector({
+ selectedCode,
+ onCodeSelect,
+ disabled,
+ placeholder = "GL 계정을 선택하세요",
+ className
+}: GlAccountSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [codes, setCodes] = useState<GlAccount[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+
+ // GL 계정 선택 핸들러
+ const handleCodeSelect = useCallback(async (code: GlAccount) => {
+ onCodeSelect(code)
+ setOpen(false)
+ }, [onCodeSelect])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<GlAccount>[] = useMemo(() => [
+ {
+ accessorKey: 'SAKNR',
+ header: '계정(G/L)',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('SAKNR')}</div>
+ ),
+ },
+ {
+ accessorKey: 'FIPEX',
+ header: '세부계정',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('FIPEX')}</div>
+ ),
+ },
+ {
+ accessorKey: 'TEXT1',
+ header: '계정명',
+ cell: ({ row }) => (
+ <div>{row.getValue('TEXT1')}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ], [handleCodeSelect])
+
+ // GL 계정 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 GL 계정 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getGlAccounts()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || 'GL 계정을 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('GL 계정 목록 로드 실패:', error)
+ toast.error('GL 계정을 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열기 핸들러
+ const handleDialogOpenChange = useCallback((newOpen: boolean) => {
+ setOpen(newOpen)
+ if (newOpen && codes.length === 0) {
+ loadCodes()
+ }
+ }, [loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ disabled={disabled}
+ className={`w-full justify-start ${className || ''}`}
+ >
+ {selectedCode ? (
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-mono text-sm">[{selectedCode.SAKNR}]</span>
+ <span className="font-mono text-sm">{selectedCode.FIPEX}</span>
+ <span className="truncate flex-1 text-left">{selectedCode.TEXT1}</span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>GL 계정 선택</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ GL 계정 조회
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="계정, 세부계정, 계정명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">GL 계정을 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 GL 계정
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/components/common/selectors/gl-account/gl-account-service.ts b/components/common/selectors/gl-account/gl-account-service.ts
new file mode 100644
index 00000000..75c82c95
--- /dev/null
+++ b/components/common/selectors/gl-account/gl-account-service.ts
@@ -0,0 +1,79 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// GL 계정 타입 정의
+export interface GlAccount {
+ SAKNR: string // 계정 (G/L)
+ FIPEX: string // 세부계정
+ TEXT1: string // 계정명
+}
+
+// 테스트 환경용 폴백 데이터
+const FALLBACK_TEST_DATA: GlAccount[] = [
+ { SAKNR: '53351977', FIPEX: 'FIP001', TEXT1: '원재료 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351978', FIPEX: 'FIP002', TEXT1: '소모품 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351979', FIPEX: 'FIP003', TEXT1: '부품 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351980', FIPEX: 'FIP004', TEXT1: '자재 구매(테스트데이터 - 오라클 페칭 실패시)' },
+ { SAKNR: '53351981', FIPEX: 'FIP005', TEXT1: '외주 가공비(테스트데이터 - 오라클 페칭 실패시)' },
+]
+
+/**
+ * GL 계정 목록 조회 (Oracle에서 전체 조회, 실패 시 폴백 데이터 사용)
+ * CMCTB_BGT_MNG_ITM 테이블에서 조회
+ */
+export async function getGlAccounts(): Promise<{
+ success: boolean
+ data: GlAccount[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getGlAccounts] Oracle 쿼리 시작...')
+
+ const result = await oracleKnex.raw(`
+ SELECT
+ SAKNR,
+ FIPEX,
+ TEXT1"
+ FROM CMCTB_BGT_MNG_ITM
+ WHERE ROWNUM < 100
+ AND BUKRS = 'H100'
+ ORDER BY SAKNR
+ `)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getGlAccounts] Oracle 쿼리 성공 - ${rows.length}건 조회`)
+
+ // null 값 필터링
+ const cleanedResult = rows
+ .filter((item) =>
+ item['계정(G/L)'] &&
+ item['세부계정']
+ )
+ .map((item) => ({
+ SAKNR: String(item['계정(G/L)']),
+ FIPEX: String(item['세부계정']),
+ TEXT1: String(item['계정명'] || '')
+ }))
+
+ console.log(`✅ [getGlAccounts] 필터링 후 ${cleanedResult.length}건`)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getGlAccounts] Oracle 오류:', error)
+ console.log('🔄 [getGlAccounts] 폴백 테스트 데이터 사용 (' + FALLBACK_TEST_DATA.length + '건)')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
diff --git a/components/common/selectors/gl-account/gl-account-single-selector.tsx b/components/common/selectors/gl-account/gl-account-single-selector.tsx
new file mode 100644
index 00000000..2a6a7915
--- /dev/null
+++ b/components/common/selectors/gl-account/gl-account-single-selector.tsx
@@ -0,0 +1,358 @@
+'use client'
+
+/**
+ * GL 계정 단일 선택 다이얼로그
+ *
+ * @description
+ * - GL 계정을 하나만 선택할 수 있는 다이얼로그
+ * - 트리거 버튼과 다이얼로그가 분리된 구조
+ * - 외부에서 open 상태를 제어 가능
+ */
+
+import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check, X } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getGlAccounts,
+ GlAccount
+} from './gl-account-service'
+import { toast } from 'sonner'
+
+export interface GlAccountSingleSelectorProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedCode?: GlAccount
+ onCodeSelect: (code: GlAccount) => void
+ onConfirm?: (code: GlAccount | undefined) => void
+ onCancel?: () => void
+ title?: string
+ description?: string
+ showConfirmButtons?: boolean
+}
+
+export function GlAccountSingleSelector({
+ open,
+ onOpenChange,
+ selectedCode,
+ onCodeSelect,
+ onConfirm,
+ onCancel,
+ title = "GL 계정 선택",
+ description = "GL 계정을 선택하세요",
+ showConfirmButtons = false
+}: GlAccountSingleSelectorProps) {
+ const [codes, setCodes] = useState<GlAccount[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+ const [tempSelectedCode, setTempSelectedCode] = useState<GlAccount | undefined>(selectedCode)
+
+ // GL 계정 선택 핸들러
+ const handleCodeSelect = useCallback((code: GlAccount) => {
+ if (showConfirmButtons) {
+ setTempSelectedCode(code)
+ } else {
+ onCodeSelect(code)
+ onOpenChange(false)
+ }
+ }, [onCodeSelect, onOpenChange, showConfirmButtons])
+
+ // 확인 버튼 핸들러
+ const handleConfirm = useCallback(() => {
+ if (tempSelectedCode) {
+ onCodeSelect(tempSelectedCode)
+ }
+ onConfirm?.(tempSelectedCode)
+ onOpenChange(false)
+ }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
+
+ // 취소 버튼 핸들러
+ const handleCancel = useCallback(() => {
+ setTempSelectedCode(selectedCode)
+ onCancel?.()
+ onOpenChange(false)
+ }, [selectedCode, onCancel, onOpenChange])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<GlAccount>[] = useMemo(() => [
+ {
+ accessorKey: 'SAKNR',
+ header: '계정(G/L)',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('SAKNR')}</div>
+ ),
+ },
+ {
+ accessorKey: 'FIPEX',
+ header: '세부계정',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('FIPEX')}</div>
+ ),
+ },
+ {
+ accessorKey: 'TEXT1',
+ header: '계정명',
+ cell: ({ row }) => (
+ <div>{row.getValue('TEXT1')}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => {
+ const isSelected = showConfirmButtons
+ ? tempSelectedCode?.SAKNR === row.original.SAKNR
+ : selectedCode?.SAKNR === row.original.SAKNR
+
+ return (
+ <Button
+ variant={isSelected ? "default" : "ghost"}
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ )
+ },
+ },
+ ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
+
+ // GL 계정 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 GL 계정 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getGlAccounts()
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || 'GL 계정을 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('GL 계정 목록 로드 실패:', error)
+ toast.error('GL 계정을 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [])
+
+ // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
+ useEffect(() => {
+ if (open) {
+ setTempSelectedCode(selectedCode)
+ if (codes.length === 0) {
+ console.log('🚀 [GlAccountSingleSelector] 다이얼로그 열림 - loadCodes 호출')
+ loadCodes()
+ } else {
+ console.log('📦 [GlAccountSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
+ }
+ }
+ }, [open, selectedCode, loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {description}
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 현재 선택된 GL 계정 표시 */}
+ {currentSelectedCode && (
+ <div className="p-3 bg-muted rounded-md">
+ <div className="text-sm font-medium">선택된 GL 계정:</div>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="font-mono text-sm">[{currentSelectedCode.SAKNR}]</span>
+ <span className="font-mono text-sm">{currentSelectedCode.FIPEX}</span>
+ <span>- {currentSelectedCode.TEXT1}</span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="계정, 세부계정, 계정명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">GL 계정을 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isRowSelected = currentSelectedCode?.SAKNR === row.original.SAKNR
+ return (
+ <TableRow
+ key={row.id}
+ data-state={isRowSelected && "selected"}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isRowSelected ? 'bg-muted' : ''
+ }`}
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 GL 계정
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ <X className="h-4 w-4 mr-2" />
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
+ <Check className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/components/common/selectors/gl-account/index.ts b/components/common/selectors/gl-account/index.ts
new file mode 100644
index 00000000..f718f13f
--- /dev/null
+++ b/components/common/selectors/gl-account/index.ts
@@ -0,0 +1,12 @@
+// GL 계정 선택기 관련 컴포넌트와 타입 내보내기
+
+export { GlAccountSelector, GlAccountSingleSelector } from './gl-account-selector'
+export type { GlAccountSelectorProps, GlAccountSingleSelectorProps, GlAccountItem } from './gl-account-selector'
+
+export {
+ getGlAccounts
+} from './gl-account-service'
+export type {
+ GlAccount
+} from './gl-account-service'
+
diff --git a/components/common/selectors/wbs-code/index.ts b/components/common/selectors/wbs-code/index.ts
new file mode 100644
index 00000000..1a4653d2
--- /dev/null
+++ b/components/common/selectors/wbs-code/index.ts
@@ -0,0 +1,12 @@
+// WBS 코드 선택기 관련 컴포넌트와 타입 내보내기
+
+export { WbsCodeSelector, WbsCodeSingleSelector } from './wbs-code-single-selector'
+export type { WbsCodeSelectorProps, WbsCodeSingleSelectorProps } from './wbs-code-single-selector'
+
+export {
+ getWbsCodes
+} from './wbs-code-service'
+export type {
+ WbsCode
+} from './wbs-code-service'
+
diff --git a/components/common/selectors/wbs-code/wbs-code-selector.tsx b/components/common/selectors/wbs-code/wbs-code-selector.tsx
new file mode 100644
index 00000000..b701d090
--- /dev/null
+++ b/components/common/selectors/wbs-code/wbs-code-selector.tsx
@@ -0,0 +1,323 @@
+'use client'
+
+/**
+ * WBS 코드 선택기
+ *
+ * @description
+ * - 오라클에서 WBS 코드들을 조회
+ * - PROJ_NO: 프로젝트 번호
+ * - WBS_ELMT: WBS 요소
+ * - WBS_ELMT_NM: WBS 요소명
+ * - WBS_LVL: WBS 레벨
+ */
+
+import { useState, useCallback, useMemo, useTransition } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getWbsCodes,
+ WbsCode
+} from './wbs-code-service'
+import { toast } from 'sonner'
+
+export interface WbsCodeSelectorProps {
+ selectedCode?: WbsCode
+ onCodeSelect: (code: WbsCode) => void
+ disabled?: boolean
+ placeholder?: string
+ className?: string
+ projNo?: string // 프로젝트 번호 필터
+}
+
+export interface WbsCodeItem {
+ code: string // WBS 코드 (PROJ_NO + WBS_ELMT 조합)
+ projNo: string // 프로젝트 번호
+ wbsElmt: string // WBS 요소
+ wbsElmtNm: string // WBS 요소명
+ wbsLvl: string // WBS 레벨
+ displayText: string // 표시용 텍스트
+}
+
+export function WbsCodeSelector({
+ selectedCode,
+ onCodeSelect,
+ disabled,
+ placeholder = "WBS 코드를 선택하세요",
+ className,
+ projNo
+}: WbsCodeSelectorProps) {
+ const [open, setOpen] = useState(false)
+ const [codes, setCodes] = useState<WbsCode[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+
+ // WBS 코드 선택 핸들러
+ const handleCodeSelect = useCallback(async (code: WbsCode) => {
+ onCodeSelect(code)
+ setOpen(false)
+ }, [onCodeSelect])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<WbsCode>[] = useMemo(() => [
+ {
+ accessorKey: 'PROJ_NO',
+ header: '프로젝트 번호',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('PROJ_NO')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_ELMT',
+ header: 'WBS 요소',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('WBS_ELMT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_ELMT_NM',
+ header: 'WBS 요소명',
+ cell: ({ row }) => (
+ <div>{row.getValue('WBS_ELMT_NM')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_LVL',
+ header: '레벨',
+ cell: ({ row }) => (
+ <div className="text-center">{row.getValue('WBS_LVL')}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ ),
+ },
+ ], [handleCodeSelect])
+
+ // WBS 코드 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 WBS 코드 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getWbsCodes(projNo)
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || 'WBS 코드를 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('WBS 코드 목록 로드 실패:', error)
+ toast.error('WBS 코드를 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [projNo])
+
+ // 다이얼로그 열기 핸들러
+ const handleDialogOpenChange = useCallback((newOpen: boolean) => {
+ setOpen(newOpen)
+ if (newOpen && codes.length === 0) {
+ loadCodes()
+ }
+ }, [loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ disabled={disabled}
+ className={`w-full justify-start ${className || ''}`}
+ >
+ {selectedCode ? (
+ <div className="flex items-center gap-2 w-full">
+ <span className="font-mono text-sm">[{selectedCode.PROJ_NO}]</span>
+ <span className="font-mono text-sm">{selectedCode.WBS_ELMT}</span>
+ <span className="truncate flex-1 text-left">{selectedCode.WBS_ELMT_NM}</span>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>WBS 코드 선택</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ WBS 코드 조회
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="프로젝트 번호, WBS 요소, WBS 요소명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">WBS 코드를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+ <TableRow
+ key={row.id}
+ data-state={row.getIsSelected() && "selected"}
+ className="cursor-pointer hover:bg-muted/50"
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ ))
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 WBS 코드
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/components/common/selectors/wbs-code/wbs-code-service.ts b/components/common/selectors/wbs-code/wbs-code-service.ts
new file mode 100644
index 00000000..7d9c17b1
--- /dev/null
+++ b/components/common/selectors/wbs-code/wbs-code-service.ts
@@ -0,0 +1,92 @@
+"use server"
+
+import { oracleKnex } from '@/lib/oracle-db/db'
+
+// WBS 코드 타입 정의
+export interface WbsCode {
+ PROJ_NO: string // 프로젝트 번호
+ WBS_ELMT: string // WBS 요소
+ WBS_ELMT_NM: string // WBS 요소명
+ WBS_LVL: string // WBS 레벨
+}
+
+// 테스트 환경용 폴백 데이터
+const FALLBACK_TEST_DATA: WbsCode[] = [
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS001', WBS_ELMT_NM: 'WBS 항목 1(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '1' },
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS002', WBS_ELMT_NM: 'WBS 항목 2(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '2' },
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS003', WBS_ELMT_NM: 'WBS 항목 3(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '1' },
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS004', WBS_ELMT_NM: 'WBS 항목 4(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '2' },
+ { PROJ_NO: 'SN2661', WBS_ELMT: 'WBS005', WBS_ELMT_NM: 'WBS 항목 5(테스트데이터 - 오라클 페칭 실패시)', WBS_LVL: '3' },
+]
+
+/**
+ * WBS 코드 목록 조회 (Oracle에서 전체 조회, 실패 시 폴백 데이터 사용)
+ * CMCTB_PROJ_WBS 테이블에서 조회
+ * @param projNo - 프로젝트 번호 (선택적, 없으면 전체 조회)
+ */
+export async function getWbsCodes(projNo?: string): Promise<{
+ success: boolean
+ data: WbsCode[]
+ error?: string
+ isUsingFallback?: boolean
+}> {
+ try {
+ console.log('📋 [getWbsCodes] Oracle 쿼리 시작...', projNo ? `프로젝트: ${projNo}` : '전체')
+
+ let query = `
+ SELECT
+ PROJ_NO,
+ WBS_ELMT,
+ WBS_ELMT_NM,
+ WBS_LVL
+ FROM CMCTB_PROJ_WBS
+ WHERE ROWNUM < 100
+ `
+
+ if (projNo) {
+ query += ` AND PROJ_NO = :projNo`
+ }
+
+ query += ` ORDER BY PROJ_NO, WBS_ELMT`
+
+ const result = projNo
+ ? await oracleKnex.raw(query, { projNo })
+ : await oracleKnex.raw(query)
+
+ // Oracle raw query의 결과는 rows 배열에 들어있음
+ const rows = (result.rows || result) as Array<Record<string, unknown>>
+
+ console.log(`✅ [getWbsCodes] Oracle 쿼리 성공 - ${rows.length}건 조회`)
+
+ // null 값 필터링
+ const cleanedResult = rows
+ .filter((item) =>
+ item.PROJ_NO &&
+ item.WBS_ELMT &&
+ item.WBS_ELMT_NM
+ )
+ .map((item) => ({
+ PROJ_NO: String(item.PROJ_NO),
+ WBS_ELMT: String(item.WBS_ELMT),
+ WBS_ELMT_NM: String(item.WBS_ELMT_NM),
+ WBS_LVL: String(item.WBS_LVL || '')
+ }))
+
+ console.log(`✅ [getWbsCodes] 필터링 후 ${cleanedResult.length}건`)
+
+ return {
+ success: true,
+ data: cleanedResult,
+ isUsingFallback: false
+ }
+ } catch (error) {
+ console.error('❌ [getWbsCodes] Oracle 오류:', error)
+ console.log('🔄 [getWbsCodes] 폴백 테스트 데이터 사용 (' + FALLBACK_TEST_DATA.length + '건)')
+ return {
+ success: true,
+ data: FALLBACK_TEST_DATA,
+ isUsingFallback: true
+ }
+ }
+}
+
diff --git a/components/common/selectors/wbs-code/wbs-code-single-selector.tsx b/components/common/selectors/wbs-code/wbs-code-single-selector.tsx
new file mode 100644
index 00000000..34cbc975
--- /dev/null
+++ b/components/common/selectors/wbs-code/wbs-code-single-selector.tsx
@@ -0,0 +1,365 @@
+/**
+ * WBS 코드 단일 선택 다이얼로그
+ *
+ * @description
+ * - WBS 코드를 하나만 선택할 수 있는 다이얼로그
+ * - 트리거 버튼과 다이얼로그가 분리된 구조
+ * - 외부에서 open 상태를 제어 가능
+ */
+
+import { useState, useCallback, useMemo, useTransition, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Search, Check, X } from 'lucide-react'
+import {
+ ColumnDef,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+ SortingState,
+ ColumnFiltersState,
+ VisibilityState,
+ RowSelectionState,
+} from '@tanstack/react-table'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ getWbsCodes,
+ WbsCode
+} from './wbs-code-service'
+import { toast } from 'sonner'
+
+export interface WbsCodeSingleSelectorProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedCode?: WbsCode
+ onCodeSelect: (code: WbsCode) => void
+ onConfirm?: (code: WbsCode | undefined) => void
+ onCancel?: () => void
+ title?: string
+ description?: string
+ showConfirmButtons?: boolean
+ projNo?: string // 프로젝트 번호 필터
+}
+
+export function WbsCodeSingleSelector({
+ open,
+ onOpenChange,
+ selectedCode,
+ onCodeSelect,
+ onConfirm,
+ onCancel,
+ title = "WBS 코드 선택",
+ description = "WBS 코드를 선택하세요",
+ showConfirmButtons = false,
+ projNo
+}: WbsCodeSingleSelectorProps) {
+ const [codes, setCodes] = useState<WbsCode[]>([])
+ const [sorting, setSorting] = useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [isPending, startTransition] = useTransition()
+ const [tempSelectedCode, setTempSelectedCode] = useState<WbsCode | undefined>(selectedCode)
+
+ // WBS 코드 선택 핸들러
+ const handleCodeSelect = useCallback((code: WbsCode) => {
+ if (showConfirmButtons) {
+ setTempSelectedCode(code)
+ } else {
+ onCodeSelect(code)
+ onOpenChange(false)
+ }
+ }, [onCodeSelect, onOpenChange, showConfirmButtons])
+
+ // 확인 버튼 핸들러
+ const handleConfirm = useCallback(() => {
+ if (tempSelectedCode) {
+ onCodeSelect(tempSelectedCode)
+ }
+ onConfirm?.(tempSelectedCode)
+ onOpenChange(false)
+ }, [tempSelectedCode, onCodeSelect, onConfirm, onOpenChange])
+
+ // 취소 버튼 핸들러
+ const handleCancel = useCallback(() => {
+ setTempSelectedCode(selectedCode)
+ onCancel?.()
+ onOpenChange(false)
+ }, [selectedCode, onCancel, onOpenChange])
+
+ // 테이블 컬럼 정의
+ const columns: ColumnDef<WbsCode>[] = useMemo(() => [
+ {
+ accessorKey: 'PROJ_NO',
+ header: '프로젝트 번호',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('PROJ_NO')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_ELMT',
+ header: 'WBS 요소',
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">{row.getValue('WBS_ELMT')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_ELMT_NM',
+ header: 'WBS 요소명',
+ cell: ({ row }) => (
+ <div>{row.getValue('WBS_ELMT_NM')}</div>
+ ),
+ },
+ {
+ accessorKey: 'WBS_LVL',
+ header: '레벨',
+ cell: ({ row }) => (
+ <div className="text-center">{row.getValue('WBS_LVL')}</div>
+ ),
+ },
+ {
+ id: 'actions',
+ header: '선택',
+ cell: ({ row }) => {
+ const isSelected = showConfirmButtons
+ ? tempSelectedCode?.WBS_ELMT === row.original.WBS_ELMT && tempSelectedCode?.PROJ_NO === row.original.PROJ_NO
+ : selectedCode?.WBS_ELMT === row.original.WBS_ELMT && selectedCode?.PROJ_NO === row.original.PROJ_NO
+
+ return (
+ <Button
+ variant={isSelected ? "default" : "ghost"}
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleCodeSelect(row.original)
+ }}
+ >
+ <Check className="h-4 w-4" />
+ </Button>
+ )
+ },
+ },
+ ], [handleCodeSelect, selectedCode, tempSelectedCode, showConfirmButtons])
+
+ // WBS 코드 테이블 설정
+ const table = useReactTable({
+ data: codes,
+ columns,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ onGlobalFilterChange: setGlobalFilter,
+ getCoreRowModel: getCoreRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ globalFilter,
+ },
+ })
+
+ // 서버에서 WBS 코드 전체 목록 로드 (한 번만)
+ const loadCodes = useCallback(async () => {
+ startTransition(async () => {
+ try {
+ const result = await getWbsCodes(projNo)
+
+ if (result.success) {
+ setCodes(result.data)
+
+ // 폴백 데이터를 사용하는 경우 알림
+ if (result.isUsingFallback) {
+ toast.info('Oracle 연결 실패', {
+ description: '테스트 데이터를 사용합니다.',
+ duration: 4000,
+ })
+ }
+ } else {
+ toast.error(result.error || 'WBS 코드를 불러오는데 실패했습니다.')
+ setCodes([])
+ }
+ } catch (error) {
+ console.error('WBS 코드 목록 로드 실패:', error)
+ toast.error('WBS 코드를 불러오는 중 오류가 발생했습니다.')
+ setCodes([])
+ }
+ })
+ }, [projNo])
+
+ // 다이얼로그 열릴 때 코드 로드 (open prop 변화 감지)
+ useEffect(() => {
+ if (open) {
+ setTempSelectedCode(selectedCode)
+ if (codes.length === 0) {
+ console.log('🚀 [WbsCodeSingleSelector] 다이얼로그 열림 - loadCodes 호출')
+ loadCodes()
+ } else {
+ console.log('📦 [WbsCodeSingleSelector] 다이얼로그 열림 - 기존 데이터 사용 (' + codes.length + '건)')
+ }
+ }
+ }, [open, selectedCode, loadCodes, codes.length])
+
+ // 검색어 변경 핸들러 (클라이언트 사이드 필터링)
+ const handleSearchChange = useCallback((value: string) => {
+ setGlobalFilter(value)
+ }, [])
+
+ const currentSelectedCode = showConfirmButtons ? tempSelectedCode : selectedCode
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[80vh]">
+ <DialogHeader>
+ <DialogTitle>{title}</DialogTitle>
+ <div className="text-sm text-muted-foreground">
+ {description}
+ </div>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 현재 선택된 WBS 코드 표시 */}
+ {currentSelectedCode && (
+ <div className="p-3 bg-muted rounded-md">
+ <div className="text-sm font-medium">선택된 WBS 코드:</div>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="font-mono text-sm">[{currentSelectedCode.PROJ_NO}]</span>
+ <span className="font-mono text-sm">{currentSelectedCode.WBS_ELMT}</span>
+ <span>{currentSelectedCode.WBS_ELMT_NM}</span>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <Search className="h-4 w-4" />
+ <Input
+ placeholder="프로젝트 번호, WBS 요소, WBS 요소명으로 검색..."
+ value={globalFilter}
+ onChange={(e) => handleSearchChange(e.target.value)}
+ className="flex-1"
+ />
+ </div>
+
+ {isPending ? (
+ <div className="flex justify-center py-8">
+ <div className="text-sm text-muted-foreground">WBS 코드를 불러오는 중...</div>
+ </div>
+ ) : (
+ <div className="border rounded-md">
+ <Table>
+ <TableHeader>
+ {table.getHeaderGroups().map((headerGroup) => (
+ <TableRow key={headerGroup.id}>
+ {headerGroup.headers.map((header) => (
+ <TableHead key={header.id}>
+ {header.isPlaceholder
+ ? null
+ : flexRender(
+ header.column.columnDef.header,
+ header.getContext()
+ )}
+ </TableHead>
+ ))}
+ </TableRow>
+ ))}
+ </TableHeader>
+ <TableBody>
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => {
+ const isRowSelected = currentSelectedCode?.WBS_ELMT === row.original.WBS_ELMT &&
+ currentSelectedCode?.PROJ_NO === row.original.PROJ_NO
+ return (
+ <TableRow
+ key={row.id}
+ data-state={isRowSelected && "selected"}
+ className={`cursor-pointer hover:bg-muted/50 ${
+ isRowSelected ? 'bg-muted' : ''
+ }`}
+ onClick={() => handleCodeSelect(row.original)}
+ >
+ {row.getVisibleCells().map((cell) => (
+ <TableCell key={cell.id}>
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+ )
+ })
+ ) : (
+ <TableRow>
+ <TableCell
+ colSpan={columns.length}
+ className="h-24 text-center"
+ >
+ 검색 결과가 없습니다.
+ </TableCell>
+ </TableRow>
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div className="text-sm text-muted-foreground">
+ 총 {table.getFilteredRowModel().rows.length}개 WBS 코드
+ </div>
+ <div className="flex items-center space-x-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.previousPage()}
+ disabled={!table.getCanPreviousPage()}
+ >
+ 이전
+ </Button>
+ <div className="text-sm">
+ {table.getState().pagination.pageIndex + 1} / {table.getPageCount()}
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => table.nextPage()}
+ disabled={!table.getCanNextPage()}
+ >
+ 다음
+ </Button>
+ </div>
+ </div>
+ </div>
+
+ {showConfirmButtons && (
+ <DialogFooter>
+ <Button variant="outline" onClick={handleCancel}>
+ <X className="h-4 w-4 mr-2" />
+ 취소
+ </Button>
+ <Button onClick={handleConfirm} disabled={!tempSelectedCode}>
+ <Check className="h-4 w-4 mr-2" />
+ 확인
+ </Button>
+ </DialogFooter>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/components/layout/HeaderSimple.tsx b/components/layout/HeaderSimple.tsx
index 425bf796..82eebf2e 100644
--- a/components/layout/HeaderSimple.tsx
+++ b/components/layout/HeaderSimple.tsx
@@ -29,14 +29,17 @@ import Image from "next/image";
import { mainNav, additionalNav, MenuSection, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; // 메뉴 구성 임포트
import { MobileMenu } from "./MobileMenu";
import { CommandMenu } from "./command-menu";
-import { useSession, signOut } from "next-auth/react";
+import { useSession } from "next-auth/react";
+import { customSignOut } from "@/lib/auth/custom-signout";
import GroupedMenuRenderer from "./GroupedMenuRender";
+import { useTranslation } from '@/i18n/client';
export function HeaderSimple() {
const params = useParams();
const lng = params?.lng as string;
const pathname = usePathname();
const { data: session } = useSession();
+ const { t } = useTranslation(lng, 'menu');
const userName = session?.user?.name || "";
const domain = session?.user?.domain || "";
@@ -149,7 +152,7 @@ export function HeaderSimple() {
<Link href={`${basePath}/settings`}>Settings</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
- <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `/${lng}/${domain}` })}>
+ <DropdownMenuItem onSelect={() => customSignOut({ callbackUrl: `${window.location.origin}${basePath}` })}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
@@ -159,7 +162,7 @@ export function HeaderSimple() {
</div>
{/* 모바일 메뉴 */}
- {isMobileMenuOpen && <MobileMenu lng={lng} onClose={toggleMobileMenu} />}
+ {isMobileMenuOpen && <MobileMenu lng={lng} onClose={toggleMobileMenu} t={t} />}
</header>
</>
);
diff --git a/components/ship-vendor-document/add-attachment-dialog.tsx b/components/ship-vendor-document/add-attachment-dialog.tsx
index 6765bcf5..4a51c3b5 100644
--- a/components/ship-vendor-document/add-attachment-dialog.tsx
+++ b/components/ship-vendor-document/add-attachment-dialog.tsx
@@ -38,7 +38,7 @@ import { useSession } from "next-auth/react"
* -----------------------------------------------------------------------------------------------*/
// 파일 검증 스키마
-const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 50MB
+const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
const ACCEPTED_FILE_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
@@ -73,7 +73,7 @@ const attachmentUploadSchema = z.object({
// .max(10, "Maximum 10 files can be uploaded")
.refine(
(files) => files.every((file) => file.size <= MAX_FILE_SIZE),
- "File size must be 50MB or less"
+ "File size must be 1GB or less"
)
// .refine(
// (files) => files.every((file) => ACCEPTED_FILE_TYPES.includes(file.type)),
@@ -101,10 +101,46 @@ function FileUploadArea({
}) {
const fileInputRef = React.useRef<HTMLInputElement>(null)
+ // 파일 검증 함수
+ const validateFiles = (filesToValidate: File[]): { valid: File[], invalid: string[] } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']
+
+ const valid: File[] = []
+ const invalid: string[] = []
+
+ filesToValidate.forEach(file => {
+ // 파일 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ invalid.push(`${file.name}: 파일 크기가 1GB를 초과합니다 (${formatFileSize(file.size)})`)
+ return
+ }
+
+ // 파일 확장자 검증
+ const extension = file.name.split('.').pop()?.toLowerCase()
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ invalid.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`)
+ return
+ }
+
+ valid.push(file)
+ })
+
+ return { valid, invalid }
+ }
+
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files || [])
if (selectedFiles.length > 0) {
- onFilesChange([...files, ...selectedFiles])
+ const { valid, invalid } = validateFiles(selectedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -112,7 +148,15 @@ function FileUploadArea({
event.preventDefault()
const droppedFiles = Array.from(event.dataTransfer.files)
if (droppedFiles.length > 0) {
- onFilesChange([...files, ...droppedFiles])
+ const { valid, invalid } = validateFiles(droppedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -147,6 +191,9 @@ function FileUploadArea({
<p className="text-xs text-muted-foreground">
Supports PDF, Word, Excel, Image, Text, ZIP, CAD files (DWG, DXF, STEP, STL, IGES) (max 1GB)
</p>
+ <p className="text-xs text-red-600 mt-1 font-medium">
+ Forbidden file types: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd
+ </p>
<p className="text-xs text-orange-600 mt-1">
Note: File names cannot contain these characters: &lt; &gt; : &quot; &apos; | ? *
</p>
diff --git a/components/ship-vendor-document/new-revision-dialog.tsx b/components/ship-vendor-document/new-revision-dialog.tsx
index 91694827..bdbb1bc6 100644
--- a/components/ship-vendor-document/new-revision-dialog.tsx
+++ b/components/ship-vendor-document/new-revision-dialog.tsx
@@ -83,10 +83,46 @@ function FileUploadArea({
}) {
const fileInputRef = React.useRef<HTMLInputElement>(null)
+ // 파일 검증 함수
+ const validateFiles = (filesToValidate: File[]): { valid: File[], invalid: string[] } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']
+
+ const valid: File[] = []
+ const invalid: string[] = []
+
+ filesToValidate.forEach(file => {
+ // 파일 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ invalid.push(`${file.name}: 파일 크기가 1GB를 초과합니다 (${formatFileSize(file.size)})`)
+ return
+ }
+
+ // 파일 확장자 검증
+ const extension = file.name.split('.').pop()?.toLowerCase()
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ invalid.push(`${file.name}: 금지된 파일 형식입니다 (.${extension})`)
+ return
+ }
+
+ valid.push(file)
+ })
+
+ return { valid, invalid }
+ }
+
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files || [])
if (selectedFiles.length > 0) {
- onFilesChange([...files, ...selectedFiles])
+ const { valid, invalid } = validateFiles(selectedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -94,7 +130,15 @@ function FileUploadArea({
event.preventDefault()
const droppedFiles = Array.from(event.dataTransfer.files)
if (droppedFiles.length > 0) {
- onFilesChange([...files, ...droppedFiles])
+ const { valid, invalid } = validateFiles(droppedFiles)
+
+ if (invalid.length > 0) {
+ invalid.forEach(msg => toast.error(msg))
+ }
+
+ if (valid.length > 0) {
+ onFilesChange([...files, ...valid])
+ }
}
}
@@ -132,6 +176,9 @@ function FileUploadArea({
<p className="text-xs text-orange-600 mt-1">
Note: File names cannot contain these characters: &lt; &gt; : &quot; &apos; | ? *
</p>
+ <p className="text-xs text-red-600 mt-1 font-medium">
+ Forbidden file types: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd
+ </p>
<input
ref={fileInputRef}
type="file"
diff --git a/db/schema/bidding.ts b/db/schema/bidding.ts
index 74603df0..18699633 100644
--- a/db/schema/bidding.ts
+++ b/db/schema/bidding.ts
@@ -10,19 +10,21 @@ import {
decimal,
boolean,
pgEnum,
- uuid,
- date, pgView
+ date,
} from 'drizzle-orm/pg-core'
import { Vendor, vendors } from './vendors'
-import { relations , sql} from 'drizzle-orm';
import { projects } from './projects';
+import { users } from './users';
+
// 입찰공고문 템플릿 (기존)
export const biddingNoticeTemplate = pgTable('bidding_notice_template', {
id: serial('id').primaryKey(),
- type: varchar('type', { length: 50 }).notNull().default('standard'), // 'standard' 고정
- title: varchar('title', { length: 200 }).notNull().default('표준 입찰공고문'),
+ biddingId: integer('bidding_id').references(() => biddings.id), // 연결된 입찰 ID (null이면 템플릿)
+ type: varchar('type', { length: 50 }).notNull().default('standard'), // 입찰공고 타입
+ title: varchar('title', { length: 200 }).notNull().default('입찰공고문'),
content: text('content').notNull().default(''),
+ isTemplate: boolean('is_template').default(false).notNull(), // 템플릿 여부
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
@@ -40,7 +42,13 @@ export const biddingStatusEnum = pgEnum('bidding_status', [
'bidding_closed', // 입찰마감
'evaluation_of_bidding', // 입찰평가중
'bidding_disposal', // 유찰
- 'vendor_selected' // 업체선정
+ 'vendor_selected', // 업체선정
+ 'bid_opening', // 개찰
+ 'early_bid_opening', // 조기개찰
+ 'rebidding', // 재입찰
+ 'disposal_cancelled', // 유찰취소
+ 'bid_closure', // 폐찰
+ 'round_increase' // 차수증가
])
// 2. 계약구분 enum
@@ -61,7 +69,6 @@ export const biddingTypeEnum = pgEnum('bidding_type', [
'transport', // 운송
'waste', // 폐기물
'sale', // 매각
- 'steel', // 강재
'other' // 기타(직접입력)
])
@@ -73,15 +80,32 @@ export const awardCountEnum = pgEnum('award_count', [
// 5. 업체 초대/응답 상태 enum
export const invitationStatusEnum = pgEnum('invitation_status', [
- 'pending', // 초대 대기
- 'sent', // 초대 발송
- 'bidding_invited', // 입찰 초대
- 'accepted', // 참여 수락
- 'declined', // 참여 거절
- 'submitted', // 견적 제출 완료
- 'bidding_submitted' // 입찰 제출 완료
+ 'pending', // 초대 대기
+ 'pre_quote_sent', // 사전견적 초대 발송
+ 'pre_quote_accepted', // 사전견적 참여
+ 'pre_quote_declined', // 사전견적 미참여
+ 'pre_quote_submitted', // 사전견적제출완료
+ 'bidding_sent', // 입찰 초대 발송
+ 'bidding_accepted', // 입찰 참여
+ 'bidding_declined', // 입찰 미참여
+ 'bidding_cancelled', // 응찰 취소
+ 'bidding_submitted' // 응찰 완료
])
+// invitationStatus 라벨 맵핑
+export const invitationStatusLabels: Record<string, string> = {
+ pending: '초대 대기',
+ pre_quote_sent: '사전견적 초대 발송',
+ pre_quote_accepted: '사전견적 참여',
+ pre_quote_declined: '사전견적 미참여',
+ pre_quote_submitted: '사전견적제출완료',
+ bidding_sent: '입찰 초대 발송',
+ bidding_accepted: '입찰 참여',
+ bidding_declined: '입찰 미참여',
+ bidding_cancelled: '응찰 취소',
+ bidding_submitted: '응찰 완료'
+}
+
// 6. 문서 타입 enum
export const documentTypeEnum = pgEnum('document_type', [
'notice', // 입찰공고서
@@ -124,8 +148,8 @@ export const weightUnitEnum = pgEnum('weight_unit', [
export const biddings = pgTable('biddings', {
id: serial('id').primaryKey(),
biddingNumber: varchar('bidding_number', { length: 50 }).unique().notNull(), // 입찰 No.
+ originalBiddingNumber: varchar('original_bidding_number', { length: 50 }), // 원입찰번호
revision: integer('revision').default(0), // Rev.
- projectId: integer('project_id').references(() => projects.id),
//견적에서 넘어온 레코드인지, 자체생산인지, 디폴트는 자체생산, notnull
biddingSourceType: varchar('bidding_source_type', { length: 20 }).notNull().default('manual'),
// 기본 정보
@@ -133,16 +157,16 @@ export const biddings = pgTable('biddings', {
itemName: varchar('item_name', { length: 300 }), // 품목명
title: varchar('title', { length: 300 }).notNull(), // 입찰명
description: text('description'),
- content: text('content'), // 입찰공고 내용 (HTML) - 표준 템플릿에서 수정된 내용
+ // 입찰공고 내용은 별도 biddingNotices 테이블에서 관리됨
// 계약 정보
contractType: contractTypeEnum('contract_type').notNull(), // 계약구분
biddingType: biddingTypeEnum('bidding_type').notNull(), // 입찰유형
awardCount: awardCountEnum('award_count').default('single'), // 낙찰수
// contractPeriod: varchar('contract_period', { length: 100 }), // 계약기간
- //시작일
- contractStartDate: date('contract_start_date'),
- //종료일
+ //시작일 (기본값: 현재 날짜)
+ contractStartDate: date('contract_start_date').defaultNow(),
+ //종료일 (기본값: 시작일로부터 1년 후)
contractEndDate: date('contract_end_date'),
// 일정 관리
@@ -170,11 +194,19 @@ export const biddings = pgTable('biddings', {
status: biddingStatusEnum('status').default('bidding_generated').notNull(),
isPublic: boolean('is_public').default(false), // 공개 입찰 여부
isUrgent: boolean('is_urgent').default(false), // 긴급여부
-
- // 담당자 정보
- managerName: varchar('manager_name', { length: 100 }), // 입찰담당자
- managerEmail: varchar('manager_email', { length: 100 }),
- managerPhone: varchar('manager_phone', { length: 20 }),
+
+ // 구매조직 정보
+ purchasingOrganization: varchar('purchasing_organization', { length: 100 }), // 구매조직
+
+ // 담당자 정보 (개선된 구조)
+ bidPicId: integer('bid_pic_id').references(() => users.id), // 입찰담당자 ID
+ bidPicName: varchar('bid_pic_name', { length: 100 }), // 입찰담당자 이름
+ bidPicCode: varchar('bid_pic_code', { length: 50 }), // 입찰담당자 코드
+ supplyPicId: integer('supply_pic_id').references(() => users.id), // 조달담당자 ID
+ supplyPicName: varchar('supply_pic_name', { length: 100 }), // 조달담당자 이름
+ supplyPicCode: varchar('supply_pic_code', { length: 50 }), // 조달담당자 코드
+
+ // 기존 담당자 정보 제거됨 (user FK로 대체)
// 메타 정보
remarks: text('remarks'), // 비고
@@ -236,9 +268,16 @@ export const prItemsForBidding = pgTable('pr_items_for_bidding', {
// 기본 정보
itemNumber: varchar('item_number', { length: 50 }), // 아이템 번호
- projectInfo: varchar('project_info', { length: 300 }), // 프로젝트 정보
+ projectId: integer('project_id').references(() => projects.id), // 프로젝트 ID (새로 추가)
+ projectInfo: varchar('project_info', { length: 300 }), // 프로젝트 정보 (기존, 추후 제거 가능)
itemInfo: varchar('item_info', { length: 300 }), // 품목정보
shi: varchar('shi', { length: 100 }), // SHI
+ // 자재 그룹 정보 (새로 추가)
+ materialGroupNumber: varchar('material_group_number', { length: 100 }), // 자재그룹번호
+ materialGroupInfo: varchar('material_group_info', { length: 300 }), // 자재그룹정보
+ // 자재 정보 (새로 추가)
+ materialNumber: varchar('material_number', { length: 100 }), // 자재번호
+ materialInfo: varchar('material_info', { length: 500 }), // 자재정보
// 납품 일정
requestedDeliveryDate: date('requested_delivery_date'), // 납품요청일
@@ -249,12 +288,41 @@ export const prItemsForBidding = pgTable('pr_items_for_bidding', {
// 수량 및 중량
quantity: decimal('quantity', { precision: 10, scale: 2 }), // 수량
- quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위
+ quantityUnit: varchar('quantity_unit', { length: 50 }), // 수량단위 (구매단위)
totalWeight: decimal('total_weight', { precision: 10, scale: 2 }), // 총 중량
- weightUnit: varchar('weight_unit', { length: 50 }), // 중량단위
+ weightUnit: varchar('weight_unit', { length: 50 }), // 중량단위 (자재순중량)
+
+ // 가격 단위 추가
+ priceUnit: varchar('price_unit', { length: 50 }), // 가격단위
+ purchaseUnit: varchar('purchase_unit', { length: 50 }), // 구매단위
+ materialWeight: decimal('material_weight', { precision: 10, scale: 2 }), // 자재순중량
+
+ // WBS 정보
+ wbsCode: varchar('wbs_code', { length: 100 }), // WBS 코드
+ wbsName: varchar('wbs_name', { length: 300 }), // WBS 명칭
+
+ // Cost Center 정보
+ costCenterCode: varchar('cost_center_code', { length: 100 }), // 코스트센터 코드
+ costCenterName: varchar('cost_center_name', { length: 300 }), // 코스트센터 명칭
+
+ // GL Account 정보
+ glAccountCode: varchar('gl_account_code', { length: 100 }), // GL 계정 코드
+ glAccountName: varchar('gl_account_name', { length: 300 }), // GL 계정 명칭
+
+ // 내정 정보 (새로 추가)
+ targetUnitPrice: decimal('target_unit_price', { precision: 15, scale: 2 }), // 내정단가
+ targetAmount: decimal('target_amount', { precision: 15, scale: 2 }), // 내정금액
+ targetCurrency: varchar('target_currency', { length: 3 }).default('KRW'), // 내정통화
+
+ // 예산 정보 (새로 추가)
+ budgetAmount: decimal('budget_amount', { precision: 15, scale: 2 }), // 예산금액
+ budgetCurrency: varchar('budget_currency', { length: 3 }).default('KRW'), // 예산통화
+
+ // 실적 정보 (새로 추가)
+ actualAmount: decimal('actual_amount', { precision: 15, scale: 2 }), // 실적금액
+ actualCurrency: varchar('actual_currency', { length: 3 }).default('KRW'), // 실적통화
- // 상세 정보
- materialDescription: text('material_description'), // 자재내역상세
+
prNumber: varchar('pr_number', { length: 50 }), // PR번호
// SPEC 파일 정보
@@ -280,7 +348,8 @@ export const biddingConditions = pgTable('bidding_conditions', {
isPriceAdjustmentApplicable: boolean('is_price_adjustment_applicable'), // 연동제적용 여부
// 무역조건
- incoterms: text('incoterms'), // Incoterms 옵션들
+ incoterms: text('incoterms'), // Incoterms 옵션들
+ incotermsOption: text('incoterms_option'), // Incoterms 옵션 (추가)
shippingPort: varchar('shipping_port', { length: 200 }), // 선적지
destinationPort: varchar('destination_port', { length: 200 }), // 하역지
@@ -298,7 +367,7 @@ export const biddingCompanies = pgTable('bidding_companies', {
companyId: integer('company_id').references(() => vendors.id).notNull(),
// 초대 및 응답 상태
- invitationStatus: invitationStatusEnum('invitation_status').default('pending').notNull(),
+ invitationStatus: invitationStatusEnum('invitation_status').notNull(),
invitedAt: timestamp('invited_at'),
respondedAt: timestamp('responded_at'),
@@ -314,9 +383,12 @@ export const biddingCompanies = pgTable('bidding_companies', {
isBiddingParticipated: boolean('is_bidding_participated'),//본입찰 참여 여부
finalQuoteAmount: decimal('final_quote_amount', { precision: 15, scale: 2 }),
finalQuoteSubmittedAt: timestamp('final_quote_submitted_at'),
+ isFinalSubmission: boolean('is_final_submission').default(false), // 최종제출 여부
isWinner: boolean('is_winner'), // 낙찰 여부
isAttendingMeeting: boolean('is_attending_meeting'), // 사양설명회 참석 여부
awardRatio: decimal('award_ratio', { precision: 5, scale: 2 }), // 발주비율
+ //연동제 적용요건 문의 여부
+ isPriceAdjustmentApplicableQuestion: boolean('is_price_adjustment_applicable_question').default(false), // 연동제 적용요건 문의 여부
// 기타
notes: text('notes'), // 특이사항
@@ -328,6 +400,18 @@ export const biddingCompanies = pgTable('bidding_companies', {
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
+// 13-1. 입찰 참여 업체 담당자 테이블
+export const biddingCompaniesContacts = pgTable('bidding_companies_contacts', {
+ id: serial('id').primaryKey(),
+ biddingId: integer('bidding_id').references(() => biddings.id).notNull(),
+ vendorId: integer('vendor_id').references(() => vendors.id).notNull(),
+ contactName: varchar('contact_name', { length: 255 }).notNull(),
+ contactEmail: varchar('contact_email', { length: 255 }).notNull(),
+ contactNumber: varchar('contact_number', { length: 50 }),
+ createdAt: timestamp('created_at').defaultNow().notNull(),
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
+})
+
// 14. 업체별 PR 아이템 응찰 정보
export const companyPrItemBids = pgTable('company_pr_item_bids', {
id: serial('id').primaryKey(),
@@ -491,7 +575,15 @@ export const priceAdjustmentForms = pgTable('price_adjustment_forms', {
});
// 타입 정의
-export type Bidding = typeof biddings.$inferSelect
+export type Bidding = typeof biddings.$inferSelect & {
+ // 추가 필드들 (쿼리 결과에 포함되므로 타입에도 추가)
+ purchasingOrganization?: string | null
+ bidPicId?: number | null
+ bidPicName?: string | null
+ supplyPicId?: number | null
+ supplyPicName?: string | null
+}
+
export type NewBidding = typeof biddings.$inferInsert
export type SpecificationMeeting = typeof specificationMeetings.$inferSelect
@@ -509,6 +601,9 @@ export type NewBiddingConditions = typeof biddingConditions.$inferInsert
export type BiddingCompany = typeof biddingCompanies.$inferSelect
export type NewBiddingCompany = typeof biddingCompanies.$inferInsert
+export type BiddingCompanyContact = typeof biddingCompaniesContacts.$inferSelect
+export type NewBiddingCompanyContact = typeof biddingCompaniesContacts.$inferInsert
+
export type CompanyPrItemBid = typeof companyPrItemBids.$inferSelect
export type NewCompanyPrItemBid = typeof companyPrItemBids.$inferInsert
@@ -543,6 +638,29 @@ export type BiddingWithDetails = Bidding & {
}
export type BiddingListItem = Bidding & {
+ // 전체 참여 현황
+ participantExpected: number // 초대업체 수
+ participationRate: number // 참여율 (입찰 기준)
+
+ // 사전견적 참여 현황
+ preQuotePending: number // 사전견적 초대 대기/발송
+ preQuoteAccepted: number // 사전견적 참여
+ preQuoteDeclined: number // 사전견적 미참여
+ preQuoteSubmitted: number // 사전견적 제출완료
+
+ // 입찰 참여 현황
+ biddingPending: number // 입찰 초대 발송
+ biddingAccepted: number // 입찰 참여
+ biddingDeclined: number // 입찰 미참여
+ biddingCancelled: number // 응찰 취소
+ biddingSubmitted: number // 응찰 완료
+
+ // 호환성을 위한 기존 필드 (deprecated)
+ participantParticipated: number // 참여
+ participantDeclined: number // 포기
+ participantPending: number // 대기
+ participantAccepted: number // 수락
+
participantStats: {
expected: number // 참여예정
participated: number // 참여
@@ -562,7 +680,13 @@ export const biddingStatusLabels = {
bidding_closed: '입찰마감',
evaluation_of_bidding: '입찰평가중',
bidding_disposal: '유찰',
- vendor_selected: '업체선정'
+ vendor_selected: '업체선정',
+ bid_opening: '개찰',
+ early_bid_opening: '조기개찰',
+ rebidding: '재입찰',
+ disposal_cancelled: '유찰취소',
+ bid_closure: '폐찰',
+ round_increase: '차수증가'
} as const
export const contractTypeLabels = {
@@ -571,6 +695,12 @@ export const contractTypeLabels = {
sale: '매각계약'
} as const
+export const biddingNoticeTypeLabels = {
+ standard: '표준',
+ facility: '시설재',
+ unit_price: '단가계약'
+} as const
+
export const biddingTypeLabels = {
equipment: '기자재',
construction: '공사',
@@ -581,7 +711,6 @@ export const biddingTypeLabels = {
transport: '운송',
waste: '폐기물',
sale: '매각',
- steel: '강재',
other: '기타(직접입력)'
} as const
@@ -610,384 +739,383 @@ export const weightUnitLabels = {
} as const
-export const biddingListView = pgView('bidding_list_view').as((qb) =>
- qb
- .select({
- // ═══════════════════════════════════════════════════════════════
- // 기본 입찰 정보
- // ═══════════════════════════════════════════════════════════════
- id: biddings.id,
- biddingNumber: biddings.biddingNumber,
- revision: biddings.revision,
- projectName: biddings.projectName,
- itemName: biddings.itemName,
- title: biddings.title,
- description: biddings.description,
- content: biddings.content,
- biddingSourceType: biddings.biddingSourceType,
- isUrgent: biddings.isUrgent,
-
- // ═══════════════════════════════════════════════════════════════
- // 계약 정보
- // ═══════════════════════════════════════════════════════════════
- contractType: biddings.contractType,
- biddingType: biddings.biddingType,
- awardCount: biddings.awardCount,
- contractStartDate: biddings.contractStartDate,
- contractEndDate: biddings.contractEndDate,
-
- // ═══════════════════════════════════════════════════════════════
- // 일정 관리
- // ═══════════════════════════════════════════════════════════════
- preQuoteDate: biddings.preQuoteDate,
- biddingRegistrationDate: biddings.biddingRegistrationDate,
- submissionStartDate: biddings.submissionStartDate,
- submissionEndDate: biddings.submissionEndDate,
- evaluationDate: biddings.evaluationDate,
-
- // ═══════════════════════════════════════════════════════════════
- // 회의 및 문서
- // ═══════════════════════════════════════════════════════════════
- hasSpecificationMeeting: biddings.hasSpecificationMeeting,
- hasPrDocument: biddings.hasPrDocument,
- prNumber: biddings.prNumber,
-
- // ═══════════════════════════════════════════════════════════════
- // 가격 정보
- // ═══════════════════════════════════════════════════════════════
- currency: biddings.currency,
- budget: biddings.budget,
- targetPrice: biddings.targetPrice,
- finalBidPrice: biddings.finalBidPrice,
-
- // ═══════════════════════════════════════════════════════════════
- // 상태 및 담당자
- // ═══════════════════════════════════════════════════════════════
- status: biddings.status,
- isPublic: biddings.isPublic,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
-
- // ═══════════════════════════════════════════════════════════════
- // 메타 정보
- // ═══════════════════════════════════════════════════════════════
- remarks: biddings.remarks,
- createdBy: biddings.createdBy,
- createdAt: biddings.createdAt,
- updatedAt: biddings.updatedAt,
- updatedBy: biddings.updatedBy,
-
- // ═══════════════════════════════════════════════════════════════
- // 사양설명회 상세 정보
- // ═══════════════════════════════════════════════════════════════
- hasSpecificationMeetingDetails: sql<boolean>`${specificationMeetings.id} IS NOT NULL`.as('has_specification_meeting_details'),
- meetingDate: specificationMeetings.meetingDate,
- meetingLocation: specificationMeetings.location,
- meetingContactPerson: specificationMeetings.contactPerson,
- meetingIsRequired: specificationMeetings.isRequired,
-
- // ═══════════════════════════════════════════════════════════════
- // PR 문서 집계
- // ═══════════════════════════════════════════════════════════════
- prDocumentCount: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM pr_documents
- WHERE bidding_id = ${biddings.id}
- ), 0)
- `.as('pr_document_count'),
-
- // PR 문서 목록 (최대 5개 문서명)
- prDocumentNames: sql<string[]>`
- (
- SELECT array_agg(document_name ORDER BY registered_at DESC)
- FROM pr_documents
- WHERE bidding_id = ${biddings.id}
- LIMIT 5
- )
- `.as('pr_document_names'),
-
- // ═══════════════════════════════════════════════════════════════
- // 참여 현황 집계 (핵심)
- // ═══════════════════════════════════════════════════════════════
- participantExpected: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- ), 0)
- `.as('participant_expected'),
-
- participantParticipated: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND invitation_status = 'submitted'
- ), 0)
- `.as('participant_participated'),
-
- participantDeclined: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND invitation_status = 'declined'
- ), 0)
- `.as('participant_declined'),
-
- participantPending: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND invitation_status IN ('pending', 'sent')
- ), 0)
- `.as('participant_pending'),
-
- participantAccepted: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND invitation_status = 'accepted'
- ), 0)
- `.as('participant_accepted'),
-
- // ═══════════════════════════════════════════════════════════════
- // 참여율 계산
- // ═══════════════════════════════════════════════════════════════
- participationRate: sql<number>`
- CASE
- WHEN (
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- ) > 0
- THEN ROUND(
- (
- SELECT count(*)::decimal
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND invitation_status = 'submitted'
- ) / (
- SELECT count(*)::decimal
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- ) * 100, 1
- )
- ELSE 0
- END
- `.as('participation_rate'),
-
- // ═══════════════════════════════════════════════════════════════
- // 견적 금액 통계
- // ═══════════════════════════════════════════════════════════════
- avgPreQuoteAmount: sql<number>`
- (
- SELECT AVG(pre_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND pre_quote_amount IS NOT NULL
- )
- `.as('avg_pre_quote_amount'),
-
- minPreQuoteAmount: sql<number>`
- (
- SELECT MIN(pre_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND pre_quote_amount IS NOT NULL
- )
- `.as('min_pre_quote_amount'),
-
- maxPreQuoteAmount: sql<number>`
- (
- SELECT MAX(pre_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND pre_quote_amount IS NOT NULL
- )
- `.as('max_pre_quote_amount'),
-
- avgFinalQuoteAmount: sql<number>`
- (
- SELECT AVG(final_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND final_quote_amount IS NOT NULL
- )
- `.as('avg_final_quote_amount'),
-
- minFinalQuoteAmount: sql<number>`
- (
- SELECT MIN(final_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND final_quote_amount IS NOT NULL
- )
- `.as('min_final_quote_amount'),
-
- maxFinalQuoteAmount: sql<number>`
- (
- SELECT MAX(final_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND final_quote_amount IS NOT NULL
- )
- `.as('max_final_quote_amount'),
-
- // ═══════════════════════════════════════════════════════════════
- // 선정 및 낙찰 정보
- // ═══════════════════════════════════════════════════════════════
- selectedForFinalBidCount: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND is_pre_quote_selected = true
- ), 0)
- `.as('selected_for_final_bid_count'),
-
- winnerCount: sql<number>`
- COALESCE((
- SELECT count(*)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND is_winner = true
- ), 0)
- `.as('winner_count'),
-
- // 낙찰 업체명 목록
- winnerCompanyNames: sql<string[]>`
- (
- SELECT array_agg(v.vendor_name ORDER BY v.vendor_name)
- FROM bidding_companies bc
- JOIN vendors v ON bc.company_id = v.id
- WHERE bc.bidding_id = ${biddings.id}
- AND bc.is_winner = true
- )
- `.as('winner_company_names'),
-
- // ═══════════════════════════════════════════════════════════════
- // 일정 상태 계산
- // ═══════════════════════════════════════════════════════════════
- submissionStatus: sql<string>`
- CASE
- WHEN ${biddings.submissionStartDate} IS NULL OR ${biddings.submissionEndDate} IS NULL
- THEN 'not_scheduled'
- WHEN NOW() < ${biddings.submissionStartDate}
- THEN 'scheduled'
- WHEN NOW() BETWEEN ${biddings.submissionStartDate} AND ${biddings.submissionEndDate}
- THEN 'active'
- WHEN NOW() > ${biddings.submissionEndDate}
- THEN 'closed'
- ELSE 'unknown'
- END
- `.as('submission_status'),
-
- // 마감까지 남은 일수
- daysUntilDeadline: sql<number>`
- CASE
- WHEN ${biddings.submissionEndDate} IS NOT NULL
- AND NOW() < ${biddings.submissionEndDate}
- THEN EXTRACT(DAYS FROM (${biddings.submissionEndDate} - NOW()))::integer
- ELSE NULL
- END
- `.as('days_until_deadline'),
-
- // 시작까지 남은 일수
- daysUntilStart: sql<number>`
- CASE
- WHEN ${biddings.submissionStartDate} IS NOT NULL
- AND NOW() < ${biddings.submissionStartDate}
- THEN EXTRACT(DAYS FROM (${biddings.submissionStartDate} - NOW()))::integer
- ELSE NULL
- END
- `.as('days_until_start'),
-
- // ═══════════════════════════════════════════════════════════════
- // 추가 유용한 계산 필드들
- // ═══════════════════════════════════════════════════════════════
-
- // 예산 대비 최저 견적 비율
- budgetEfficiencyRate: sql<number>`
- CASE
- WHEN ${biddings.budget} IS NOT NULL AND ${biddings.budget} > 0
- AND (
- SELECT MIN(final_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND final_quote_amount IS NOT NULL
- ) IS NOT NULL
- THEN ROUND(
- (
- SELECT MIN(final_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND final_quote_amount IS NOT NULL
- ) / ${biddings.budget} * 100, 1
- )
- ELSE NULL
- END
- `.as('budget_efficiency_rate'),
-
- // 내정가 대비 최저 견적 비율
- targetPriceEfficiencyRate: sql<number>`
- CASE
- WHEN ${biddings.targetPrice} IS NOT NULL AND ${biddings.targetPrice} > 0
- AND (
- SELECT MIN(final_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND final_quote_amount IS NOT NULL
- ) IS NOT NULL
- THEN ROUND(
- (
- SELECT MIN(final_quote_amount)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- AND final_quote_amount IS NOT NULL
- ) / ${biddings.targetPrice} * 100, 1
- )
- ELSE NULL
- END
- `.as('target_price_efficiency_rate'),
-
- // 입찰 진행 단계 점수 (0-100)
- progressScore: sql<number>`
- CASE ${biddings.status}
- WHEN 'bidding_generated' THEN 10
- WHEN 'request_for_quotation' THEN 20
- WHEN 'received_quotation' THEN 40
- WHEN 'set_target_price' THEN 60
- WHEN 'bidding_opened' THEN 70
- WHEN 'bidding_closed' THEN 80
- WHEN 'evaluation_of_bidding' THEN 90
- WHEN 'vendor_selected' THEN 100
- WHEN 'bidding_disposal' THEN 0
- ELSE 0
- END
- `.as('progress_score'),
-
- // 마지막 활동일 (가장 최근 업체 응답일)
- lastActivityDate: sql<Date>`
- GREATEST(
- ${biddings.updatedAt},
- COALESCE((
- SELECT MAX(updated_at)
- FROM bidding_companies
- WHERE bidding_id = ${biddings.id}
- ), ${biddings.updatedAt})
- )
- `.as('last_activity_date'),
- })
- .from(biddings)
- .leftJoin(
- specificationMeetings,
- sql`${biddings.id} = ${specificationMeetings.biddingId}`
- )
-)
-
-export type BiddingListView = typeof biddingListView.$inferSelect
+// export const biddingListView = pgView('bidding_list_view').as((qb) =>
+// qb
+// .select({
+// // ═══════════════════════════════════════════════════════════════
+// // 기본 입찰 정보
+// // ═══════════════════════════════════════════════════════════════
+// id: biddings.id,
+// biddingNumber: biddings.biddingNumber,
+// revision: biddings.revision,
+// projectName: biddings.projectName,
+// itemName: biddings.itemName,
+// title: biddings.title,
+// description: biddings.description,
+// biddingSourceType: biddings.biddingSourceType,
+// isUrgent: biddings.isUrgent,
+
+// // ═══════════════════════════════════════════════════════════════
+// // 계약 정보
+// // ═══════════════════════════════════════════════════════════════
+// contractType: biddings.contractType,
+// biddingType: biddings.biddingType,
+// awardCount: biddings.awardCount,
+// contractStartDate: biddings.contractStartDate,
+// contractEndDate: biddings.contractEndDate,
+
+// // ═══════════════════════════════════════════════════════════════
+// // 일정 관리
+// // ═══════════════════════════════════════════════════════════════
+// preQuoteDate: biddings.preQuoteDate,
+// biddingRegistrationDate: biddings.biddingRegistrationDate,
+// submissionStartDate: biddings.submissionStartDate,
+// submissionEndDate: biddings.submissionEndDate,
+// evaluationDate: biddings.evaluationDate,
+
+// // ═══════════════════════════════════════════════════════════════
+// // 회의 및 문서
+// // ═══════════════════════════════════════════════════════════════
+// hasSpecificationMeeting: biddings.hasSpecificationMeeting,
+// hasPrDocument: biddings.hasPrDocument,
+// prNumber: biddings.prNumber,
+
+// // ═══════════════════════════════════════════════════════════════
+// // 가격 정보
+// // ═══════════════════════════════════════════════════════════════
+// currency: biddings.currency,
+// budget: biddings.budget,
+// targetPrice: biddings.targetPrice,
+// finalBidPrice: biddings.finalBidPrice,
+
+// // ═══════════════════════════════════════════════════════════════
+// // 상태 및 담당자
+// // ═══════════════════════════════════════════════════════════════
+// status: biddings.status,
+// isPublic: biddings.isPublic,
+// managerName: biddings.managerName,
+// managerEmail: biddings.managerEmail,
+// managerPhone: biddings.managerPhone,
+
+// // ═══════════════════════════════════════════════════════════════
+// // 메타 정보
+// // ═══════════════════════════════════════════════════════════════
+// remarks: biddings.remarks,
+// createdBy: biddings.createdBy,
+// createdAt: biddings.createdAt,
+// updatedAt: biddings.updatedAt,
+// updatedBy: biddings.updatedBy,
+
+// // ═══════════════════════════════════════════════════════════════
+// // 사양설명회 상세 정보
+// // ═══════════════════════════════════════════════════════════════
+// hasSpecificationMeetingDetails: sql<boolean>`${specificationMeetings.id} IS NOT NULL`.as('has_specification_meeting_details'),
+// meetingDate: specificationMeetings.meetingDate,
+// meetingLocation: specificationMeetings.location,
+// meetingContactPerson: specificationMeetings.contactPerson,
+// meetingIsRequired: specificationMeetings.isRequired,
+
+// // ═══════════════════════════════════════════════════════════════
+// // PR 문서 집계
+// // ═══════════════════════════════════════════════════════════════
+// prDocumentCount: sql<number>`
+// COALESCE((
+// SELECT count(*)
+// FROM pr_documents
+// WHERE bidding_id = ${biddings.id}
+// ), 0)
+// `.as('pr_document_count'),
+
+// // PR 문서 목록 (최대 5개 문서명)
+// prDocumentNames: sql<string[]>`
+// (
+// SELECT array_agg(document_name ORDER BY registered_at DESC)
+// FROM pr_documents
+// WHERE bidding_id = ${biddings.id}
+// LIMIT 5
+// )
+// `.as('pr_document_names'),
+
+// // ═══════════════════════════════════════════════════════════════
+// // 참여 현황 집계 (핵심)
+// // ═══════════════════════════════════════════════════════════════
+// participantExpected: sql<number>`
+// COALESCE((
+// SELECT count(*)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// ), 0)
+// `.as('participant_expected'),
+
+// participantParticipated: sql<number>`
+// COALESCE((
+// SELECT count(*)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND invitation_status = 'submitted'
+// ), 0)
+// `.as('participant_participated'),
+
+// participantDeclined: sql<number>`
+// COALESCE((
+// SELECT count(*)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND invitation_status = 'declined'
+// ), 0)
+// `.as('participant_declined'),
+
+// participantPending: sql<number>`
+// COALESCE((
+// SELECT count(*)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND invitation_status IN ('pending', 'sent')
+// ), 0)
+// `.as('participant_pending'),
+
+// participantAccepted: sql<number>`
+// COALESCE((
+// SELECT count(*)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND invitation_status = 'accepted'
+// ), 0)
+// `.as('participant_accepted'),
+
+// // ═══════════════════════════════════════════════════════════════
+// // 참여율 계산
+// // ═══════════════════════════════════════════════════════════════
+// participationRate: sql<number>`
+// CASE
+// WHEN (
+// SELECT count(*)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// ) > 0
+// THEN ROUND(
+// (
+// SELECT count(*)::decimal
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND invitation_status = 'submitted'
+// ) / (
+// SELECT count(*)::decimal
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// ) * 100, 1
+// )
+// ELSE 0
+// END
+// `.as('participation_rate'),
+
+// // ═══════════════════════════════════════════════════════════════
+// // 견적 금액 통계
+// // ═══════════════════════════════════════════════════════════════
+// avgPreQuoteAmount: sql<number>`
+// (
+// SELECT AVG(pre_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND pre_quote_amount IS NOT NULL
+// )
+// `.as('avg_pre_quote_amount'),
+
+// minPreQuoteAmount: sql<number>`
+// (
+// SELECT MIN(pre_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND pre_quote_amount IS NOT NULL
+// )
+// `.as('min_pre_quote_amount'),
+
+// maxPreQuoteAmount: sql<number>`
+// (
+// SELECT MAX(pre_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND pre_quote_amount IS NOT NULL
+// )
+// `.as('max_pre_quote_amount'),
+
+// avgFinalQuoteAmount: sql<number>`
+// (
+// SELECT AVG(final_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND final_quote_amount IS NOT NULL
+// )
+// `.as('avg_final_quote_amount'),
+
+// minFinalQuoteAmount: sql<number>`
+// (
+// SELECT MIN(final_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND final_quote_amount IS NOT NULL
+// )
+// `.as('min_final_quote_amount'),
+
+// maxFinalQuoteAmount: sql<number>`
+// (
+// SELECT MAX(final_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND final_quote_amount IS NOT NULL
+// )
+// `.as('max_final_quote_amount'),
+
+// // ═══════════════════════════════════════════════════════════════
+// // 선정 및 낙찰 정보
+// // ═══════════════════════════════════════════════════════════════
+// selectedForFinalBidCount: sql<number>`
+// COALESCE((
+// SELECT count(*)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND is_pre_quote_selected = true
+// ), 0)
+// `.as('selected_for_final_bid_count'),
+
+// winnerCount: sql<number>`
+// COALESCE((
+// SELECT count(*)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND is_winner = true
+// ), 0)
+// `.as('winner_count'),
+
+// // 낙찰 업체명 목록
+// winnerCompanyNames: sql<string[]>`
+// (
+// SELECT array_agg(v.vendor_name ORDER BY v.vendor_name)
+// FROM bidding_companies bc
+// JOIN vendors v ON bc.company_id = v.id
+// WHERE bc.bidding_id = ${biddings.id}
+// AND bc.is_winner = true
+// )
+// `.as('winner_company_names'),
+
+// // ═══════════════════════════════════════════════════════════════
+// // 일정 상태 계산
+// // ═══════════════════════════════════════════════════════════════
+// submissionStatus: sql<string>`
+// CASE
+// WHEN ${biddings.submissionStartDate} IS NULL OR ${biddings.submissionEndDate} IS NULL
+// THEN 'not_scheduled'
+// WHEN NOW() < ${biddings.submissionStartDate}
+// THEN 'scheduled'
+// WHEN NOW() BETWEEN ${biddings.submissionStartDate} AND ${biddings.submissionEndDate}
+// THEN 'active'
+// WHEN NOW() > ${biddings.submissionEndDate}
+// THEN 'closed'
+// ELSE 'unknown'
+// END
+// `.as('submission_status'),
+
+// // 마감까지 남은 일수
+// daysUntilDeadline: sql<number>`
+// CASE
+// WHEN ${biddings.submissionEndDate} IS NOT NULL
+// AND NOW() < ${biddings.submissionEndDate}
+// THEN EXTRACT(DAYS FROM (${biddings.submissionEndDate} - NOW()))::integer
+// ELSE NULL
+// END
+// `.as('days_until_deadline'),
+
+// // 시작까지 남은 일수
+// daysUntilStart: sql<number>`
+// CASE
+// WHEN ${biddings.submissionStartDate} IS NOT NULL
+// AND NOW() < ${biddings.submissionStartDate}
+// THEN EXTRACT(DAYS FROM (${biddings.submissionStartDate} - NOW()))::integer
+// ELSE NULL
+// END
+// `.as('days_until_start'),
+
+// // ═══════════════════════════════════════════════════════════════
+// // 추가 유용한 계산 필드들
+// // ═══════════════════════════════════════════════════════════════
+
+// // 예산 대비 최저 견적 비율
+// budgetEfficiencyRate: sql<number>`
+// CASE
+// WHEN ${biddings.budget} IS NOT NULL AND ${biddings.budget} > 0
+// AND (
+// SELECT MIN(final_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND final_quote_amount IS NOT NULL
+// ) IS NOT NULL
+// THEN ROUND(
+// (
+// SELECT MIN(final_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND final_quote_amount IS NOT NULL
+// ) / ${biddings.budget} * 100, 1
+// )
+// ELSE NULL
+// END
+// `.as('budget_efficiency_rate'),
+
+// // 내정가 대비 최저 견적 비율
+// targetPriceEfficiencyRate: sql<number>`
+// CASE
+// WHEN ${biddings.targetPrice} IS NOT NULL AND ${biddings.targetPrice} > 0
+// AND (
+// SELECT MIN(final_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND final_quote_amount IS NOT NULL
+// ) IS NOT NULL
+// THEN ROUND(
+// (
+// SELECT MIN(final_quote_amount)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// AND final_quote_amount IS NOT NULL
+// ) / ${biddings.targetPrice} * 100, 1
+// )
+// ELSE NULL
+// END
+// `.as('target_price_efficiency_rate'),
+
+// // 입찰 진행 단계 점수 (0-100)
+// progressScore: sql<number>`
+// CASE ${biddings.status}
+// WHEN 'bidding_generated' THEN 10
+// WHEN 'request_for_quotation' THEN 20
+// WHEN 'received_quotation' THEN 40
+// WHEN 'set_target_price' THEN 60
+// WHEN 'bidding_opened' THEN 70
+// WHEN 'bidding_closed' THEN 80
+// WHEN 'evaluation_of_bidding' THEN 90
+// WHEN 'vendor_selected' THEN 100
+// WHEN 'bidding_disposal' THEN 0
+// ELSE 0
+// END
+// `.as('progress_score'),
+
+// // 마지막 활동일 (가장 최근 업체 응답일)
+// lastActivityDate: sql<Date>`
+// GREATEST(
+// ${biddings.updatedAt},
+// COALESCE((
+// SELECT MAX(updated_at)
+// FROM bidding_companies
+// WHERE bidding_id = ${biddings.id}
+// ), ${biddings.updatedAt})
+// )
+// `.as('last_activity_date'),
+// })
+// .from(biddings)
+// .leftJoin(
+// specificationMeetings,
+// sql`${biddings.id} = ${specificationMeetings.biddingId}`
+// )
+// )
+
+// export type BiddingListView = typeof biddingListView.$inferSelect
diff --git a/db/schema/generalContract.ts b/db/schema/generalContract.ts
index 29479b20..6f48581f 100644
--- a/db/schema/generalContract.ts
+++ b/db/schema/generalContract.ts
@@ -47,7 +47,6 @@ export const generalContracts = pgTable('general_contracts', {
// 협력업체 및 계약 기간
// ═══════════════════════════════════════════════════════════════
vendorId: integer('vendor_id').notNull().references(() => vendors.id), // 협력업체 ID
- projectId: integer('project_id').references(() => projects.id), // 프로젝트 ID (nullable)
startDate: date('start_date'), // 계약 시작일
endDate: date('end_date'), // 계약 종료일
validityEndDate: date('validity_end_date'), // 계약 유효기간 종료일
@@ -105,6 +104,8 @@ export const generalContracts = pgTable('general_contracts', {
interlockingSystem: varchar('interlocking_system', { length: 10 }), // 연동제적용 (Y/N)
mandatoryDocuments: jsonb('mandatory_documents').default({}), // 필수문서동의
contractTerminationConditions: jsonb('contract_termination_conditions').default({}), // 계약해지조건
+ externalYardEntry: varchar('external_yard_entry', { length: 1 }), // 사외업체 야드투입 (Y/N)
+ contractAmountReason: text('contract_amount_reason'), // 합의계약 미확정 사유
// ═══════════════════════════════════════════════════════════════
// 기타 계약 조건 및 약관 (JSON 형태)
@@ -117,6 +118,12 @@ export const generalContracts = pgTable('general_contracts', {
offsetDetails: jsonb('offset_details').default({}), // 회입/상계내역
// ═══════════════════════════════════════════════════════════════
+ // 조건검토 의견
+ // ═══════════════════════════════════════════════════════════════
+ vendorComment: text('vendor_comment'), // 협력업체 조건검토 의견
+ shiComment: text('shi_comment'), // 당사 조건검토 의견
+
+ // ═══════════════════════════════════════════════════════════════
// 시스템 관리 정보
// ═══════════════════════════════════════════════════════════════
registeredById: integer('registered_by_id').notNull().references(() => users.id), // 등록자 ID
@@ -133,6 +140,7 @@ export const generalContractItems = pgTable('general_contract_items', {
// ═══════════════════════════════════════════════════════════════
id: serial('id').primaryKey(), // 품목 고유 ID
contractId: integer('contract_id').notNull().references(() => generalContracts.id), // 계약 ID (외래키)
+ projectId: integer('project_id').references(() => projects.id), // 프로젝트 ID (nullable)
// ═══════════════════════════════════════════════════════════════
// 품목 기본 정보
@@ -185,6 +193,10 @@ export const generalContractItemsRelations = relations(generalContractItems, ({
fields: [generalContractItems.contractId],
references: [generalContracts.id],
}),
+ project: one(projects, {
+ fields: [generalContractItems.projectId],
+ references: [projects.id],
+ }),
}));
export const generalContractsRelations = relations(generalContracts, ({ one, many }) => ({
manager: one(users, {
@@ -201,10 +213,6 @@ export const generalContractsRelations = relations(generalContracts, ({ one, man
fields: [generalContracts.vendorId],
references: [vendors.id],
}),
- project: one(projects, {
- fields: [generalContracts.projectId],
- references: [projects.id],
- }),
items: many(generalContractItems),
attachments: many(generalContractAttachments),
}));
diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json
index 5a7e3297..ef74943e 100644
--- a/i18n/locales/en/menu.json
+++ b/i18n/locales/en/menu.json
@@ -29,7 +29,8 @@
"legal": "Legal Review",
"basic_contract": "Basic Contract Management",
"engineering_management": "Engineering Management",
- "engineering_in_procurement":"Procurement Engineering"
+ "engineering_in_procurement":"Procurement Engineering",
+ "system": "System"
},
"menu": {
"master_data": {
@@ -195,8 +196,8 @@
"email_log": "Email Transmission History Inquiry",
"email_whitelist": "Send Email OTP Whitelist Management",
"login_history": "Login/Logout History Inquiry",
- "page_visits": "Page Access History Inquiry"
-
+ "page_visits": "Page Access History Inquiry",
+ "change_vendor": "Change Vendor"
},
"vendor": {
"bidding": {
diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json
index 44e41e59..fbc383d5 100644
--- a/i18n/locales/ko/menu.json
+++ b/i18n/locales/ko/menu.json
@@ -29,7 +29,8 @@
"basic_contract": "기본계약 관리",
"engineering_management": "설계 관리",
"propose": "견적 및 입찰",
- "engineering_in_procurement": "구매 설계"
+ "engineering_in_procurement": "구매 설계",
+ "system": "시스템"
},
"menu": {
"master_data": {
@@ -199,7 +200,8 @@
"email_log": "이메일 발신 이력 조회",
"email_whitelist": "이메일 OTP 화이트리스트 관리",
"login_history": "로그인/아웃 이력 조회",
- "page_visits": "페이지 접속 이력 조회"
+ "page_visits": "페이지 접속 이력 조회",
+ "change_vendor": "벤더 변경"
},
"vendor": {
"bidding": {
diff --git a/lib/bidding/actions.ts b/lib/bidding/actions.ts
index df9d0dad..b5736707 100644
--- a/lib/bidding/actions.ts
+++ b/lib/bidding/actions.ts
@@ -1,475 +1,590 @@
-"use server"
-
-import db from "@/db/db"
-import { eq, and } from "drizzle-orm"
-import {
- biddings,
- biddingCompanies,
- prItemsForBidding,
- companyPrItemBids,
- vendors,
- generalContracts,
- generalContractItems,
- biddingConditions
-} from "@/db/schema"
-import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po"
-import { getCurrentSAPDate } from "@/lib/soap/utils"
-import { generateContractNumber } from "@/lib/general-contracts/service"
-
-// TO Contract
-export async function transmitToContract(biddingId: number, userId: number) {
- try {
- // 1. 입찰 정보 조회 (단순 쿼리)
- const bidding = await db.select()
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
-
- if (!bidding || bidding.length === 0) {
- throw new Error("입찰 정보를 찾을 수 없습니다.")
- }
-
- const biddingData = bidding[0]
-
- // 2. 입찰 조건 정보 조회
- const biddingConditionData = await db.select()
- .from(biddingConditions)
- .where(eq(biddingConditions.biddingId, biddingId))
- .limit(1)
-
- const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null
-
- // 3. 낙찰된 업체들 조회 (biddingCompanies.id 포함)
- const winnerCompaniesData = await db.select({
- id: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- awardRatio: biddingCompanies.awardRatio,
- vendorCode: vendors.vendorCode,
- vendorName: vendors.vendorName
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isWinner, true)
- )
- )
-
- // 상태 검증
- if (biddingData.status !== 'vendor_selected') {
- throw new Error("업체 선정이 완료되지 않은 입찰입니다.")
- }
-
- // 낙찰된 업체 검증
- if (winnerCompaniesData.length === 0) {
- throw new Error("낙찰된 업체가 없습니다.")
- }
-
- // 일반/매각 입찰의 경우 비율 합계 100% 검증
- const contractType = biddingData.contractType
- if (contractType === 'general' || contractType === 'sale') {
- const totalRatio = winnerCompaniesData.reduce((sum, company) =>
- sum + (Number(company.awardRatio) || 0), 0)
-
- if (totalRatio !== 100) {
- throw new Error(`일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%`)
- }
- }
-
- for (const winnerCompany of winnerCompaniesData) {
- // winnerCompany에서 직접 정보 사용
- const awardRatio = (Number(winnerCompany.awardRatio) || 100) / 100
- const biddingCompanyId = winnerCompany.id
-
- // 현재 winnerCompany의 입찰 데이터 조회
- const companyBids = await db.select({
- prItemId: companyPrItemBids.prItemId,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- bidUnitPrice: companyPrItemBids.bidUnitPrice,
- bidAmount: companyPrItemBids.bidAmount,
- currency: companyPrItemBids.currency,
- // PR 아이템 정보도 함께 조회
- itemNumber: prItemsForBidding.itemNumber,
- itemInfo: prItemsForBidding.itemInfo,
- materialDescription: prItemsForBidding.materialDescription,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- })
- .from(companyPrItemBids)
- .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
- .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyId))
-
- // 발주비율에 따른 최종 계약금액 계산
- let totalContractAmount = 0
- if (companyBids.length > 0) {
- for (const bid of companyBids) {
- const originalQuantity = Number(bid.quantity) || 0
- const bidUnitPrice = Number(bid.bidUnitPrice) || 0
- const finalQuantity = originalQuantity * awardRatio
- const finalAmount = finalQuantity * bidUnitPrice
- totalContractAmount += finalAmount
- }
- }
-
- // 계약 번호 자동 생성 (실제 규칙에 맞게)
- const contractNumber = await generateContractNumber(userId.toString(), biddingData.contractType)
- console.log('Generated contractNumber:', contractNumber)
-
- // general-contract 생성 (발주비율 계산된 최종 금액 사용)
- const contractResult = await db.insert(generalContracts).values({
- contractNumber,
- revision: 0,
- contractSourceType: 'bid', // 입찰에서 생성됨
- status: 'Draft',
- category: biddingData.contractType || 'general',
- name: biddingData.title,
- vendorId: winnerCompany.companyId,
- linkedBidNumber: biddingData.biddingNumber,
- contractAmount: totalContractAmount ? totalContractAmount.toString() as any : null, // 발주비율 계산된 최종 금액 사용
- startDate: biddingData.contractStartDate || null,
- endDate: biddingData.contractEndDate || null,
- currency: biddingData.currency || 'KRW',
- // 계약 조건 정보 추가
- paymentTerm: biddingCondition?.paymentTerms || null,
- taxType: biddingCondition?.taxConditions || 'V0',
- deliveryTerm: biddingCondition?.incoterms || 'FOB',
- shippingLocation: biddingCondition?.shippingPort || null,
- dischargeLocation: biddingCondition?.destinationPort || null,
- registeredById: userId,
- lastUpdatedById: userId,
- }).returning({ id: generalContracts.id })
- console.log('contractResult', contractResult)
- const contractId = contractResult[0].id
-
- // 현재 winnerCompany의 품목정보 생성 (발주비율 적용)
- if (companyBids.length > 0) {
- console.log(`Creating ${companyBids.length} contract items for winner company ${winnerCompany.companyId} with award ratio ${awardRatio}`)
- for (const bid of companyBids) {
- // 발주비율에 따른 최종 수량 계산 (중량 제외)
- const originalQuantity = Number(bid.quantity) || 0
- const bidUnitPrice = Number(bid.bidUnitPrice) || 0
-
- const finalQuantity = originalQuantity * awardRatio
- const finalAmount = finalQuantity * bidUnitPrice
-
- await db.insert(generalContractItems).values({
- contractId: contractId,
- itemCode: bid.itemNumber || '',
- itemInfo: bid.itemInfo || '',
- specification: bid.materialDescription || '',
- quantity: finalQuantity || null,
- quantityUnit: bid.quantityUnit || '',
- totalWeight: null, // 중량 정보 제외
- weightUnit: '', // 중량 단위 제외
- contractDeliveryDate: bid.proposedDeliveryDate || null,
- contractUnitPrice: bid.bidUnitPrice || null,
- contractAmount: finalAmount ? finalAmount.toString() as any : null,
- contractCurrency: bid.currency || biddingData.currency || 'KRW',
- })
- }
- console.log(`Created ${companyBids.length} contract items for winner company ${winnerCompany.companyId}`)
- } else {
- console.log(`No bid data found for winner company ${winnerCompany.companyId}`)
- }
- }
-
- return { success: true, message: `${winnerCompaniesData.length}개의 계약서가 생성되었습니다.` }
-
- } catch (error) {
- console.error('TO Contract 실패:', error)
- throw new Error(error instanceof Error ? error.message : '계약서 생성에 실패했습니다.')
- }
-}
-
-// TO PO
-export async function transmitToPO(biddingId: number) {
- try {
- // 1. 입찰 정보 조회
- const biddingData = await db.select()
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
-
- if (!biddingData || biddingData.length === 0) {
- throw new Error("입찰 정보를 찾을 수 없습니다.")
- }
-
- const bidding = biddingData[0]
-
- if (bidding.status !== 'vendor_selected') {
- throw new Error("업체 선정이 완료되지 않은 입찰입니다.")
- }
-
- // 2. 입찰 조건 정보 조회
- const biddingConditionData = await db.select()
- .from(biddingConditions)
- .where(eq(biddingConditions.biddingId, biddingId))
- .limit(1)
-
- const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null
-
- // 3. 낙찰된 업체들 조회 (발주비율 포함)
- const winnerCompaniesRaw = await db.select({
- id: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- awardRatio: biddingCompanies.awardRatio,
- vendorCode: vendors.vendorCode,
- vendorName: vendors.vendorName
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isWinner, true)
- )
- )
-
- if (winnerCompaniesRaw.length === 0) {
- throw new Error("낙찰된 업체가 없습니다.")
- }
-
- // 일반/매각 입찰의 경우 비율 합계 100% 검증
- const contractType = bidding.contractType
- if (contractType === 'general' || contractType === 'sale') {
- const totalRatio = winnerCompaniesRaw.reduce((sum, company) =>
- sum + (Number(company.awardRatio) || 0), 0)
-
- if (totalRatio !== 100) {
- throw new Error(`일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%`)
- }
- }
-
- // 4. 낙찰된 업체들의 입찰 데이터 조회 (발주비율 적용)
- type POItem = {
- prItemId: number
- proposedDeliveryDate: string | null
- bidUnitPrice: string | null
- bidAmount: string | null
- currency: string | null
- itemNumber: string | null
- itemInfo: string | null
- materialDescription: string | null
- quantity: string | null
- quantityUnit: string | null
- finalQuantity: number
- finalAmount: number
- awardRatio: number
- vendorCode: string | null
- vendorName: string | null
- companyId: number
- }
- const poItems: POItem[] = []
- for (const winner of winnerCompaniesRaw) {
- const awardRatio = (Number(winner.awardRatio) || 100) / 100
-
- const companyBids = await db.select({
- prItemId: companyPrItemBids.prItemId,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- bidUnitPrice: companyPrItemBids.bidUnitPrice,
- bidAmount: companyPrItemBids.bidAmount,
- currency: companyPrItemBids.currency,
- // PR 아이템 정보
- itemNumber: prItemsForBidding.itemNumber,
- itemInfo: prItemsForBidding.itemInfo,
- materialDescription: prItemsForBidding.materialDescription,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- })
- .from(companyPrItemBids)
- .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
- .where(eq(companyPrItemBids.biddingCompanyId, winner.id))
-
- // 발주비율 적용하여 PO 아이템 생성 (중량 제외)
- for (const bid of companyBids) {
- const originalQuantity = Number(bid.quantity) || 0
- const bidUnitPrice = Number(bid.bidUnitPrice) || 0
-
- const finalQuantity = originalQuantity * awardRatio
- const finalAmount = finalQuantity * bidUnitPrice
-
- poItems.push({
- ...bid,
- finalQuantity,
- finalAmount,
- awardRatio,
- vendorCode: winner.vendorCode,
- vendorName: winner.vendorName,
- companyId: winner.companyId,
- } as POItem)
- }
- }
-
- // 5. PO 데이터 구성 (bidding condition 정보와 발주비율 적용된 데이터 사용)
- const poData = {
- T_Bidding_HEADER: winnerCompaniesRaw.map((company) => ({
- ANFNR: bidding.biddingNumber,
- LIFNR: company.vendorCode || `VENDOR${company.companyId}`,
- ZPROC_IND: 'A', // 구매 처리 상태
- ANGNR: bidding.biddingNumber,
- WAERS: bidding.currency || 'KRW',
- ZTERM: biddingCondition?.paymentTerms || '0001', // 지급조건
- INCO1: biddingCondition?.incoterms || 'FOB', // Incoterms
- INCO2: biddingCondition?.destinationPort || biddingCondition?.shippingPort || 'Seoul, Korea',
- MWSKZ: biddingCondition?.taxConditions || 'V0', // 세금 코드
- LANDS: 'KR',
- ZRCV_DT: getCurrentSAPDate(),
- ZATTEN_IND: 'Y',
- IHRAN: getCurrentSAPDate(),
- TEXT: `PO from Bidding: ${bidding.title}`,
- })),
- T_Bidding_ITEM: poItems.map((item, index) => ({
- ANFNR: bidding.biddingNumber,
- ANFPS: (index + 1).toString().padStart(5, '0'),
- LIFNR: item.vendorCode || `VENDOR${item.companyId}`,
- NETPR: item.bidUnitPrice?.toString() || '0',
- PEINH: '1',
- BPRME: item.quantityUnit || 'EA',
- NETWR: item.finalAmount?.toString() || '0',
- BRTWR: (Number(item.finalAmount || 0) * 1.1).toString(), // 10% 부가세 가정
- LFDAT: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate).toISOString().split('T')[0] : getCurrentSAPDate(),
- })),
- T_PR_RETURN: [{
- ANFNR: bidding.biddingNumber,
- ANFPS: '00001',
- EBELN: `PR${bidding.biddingNumber}`,
- EBELP: '00001',
- MSGTY: 'S',
- MSGTXT: 'Success'
- }]
- }
-
- // 3. SAP으로 PO 전송
- console.log('SAP으로 PO 전송할 poData', poData)
- const result = await createPurchaseOrder(poData)
-
- if (!result.success) {
- throw new Error(result.message)
- }
-
- return { success: true, message: result.message }
-
- } catch (error) {
- console.error('TO PO 실패:', error)
- throw new Error(error instanceof Error ? error.message : 'PO 전송에 실패했습니다.')
- }
-}
-
-// 낙찰된 업체들의 상세 정보 조회 (발주비율에 따른 계산 포함)
-export async function getWinnerDetails(biddingId: number) {
- try {
- // 1. 입찰 정보 조회 (contractType 포함)
- const biddingInfo = await db.select({
- contractType: biddings.contractType,
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
-
- if (!biddingInfo || biddingInfo.length === 0) {
- return { success: false, error: '입찰 정보를 찾을 수 없습니다.' }
- }
-
- // 2. 낙찰된 업체들 조회
- const winnerCompanies = await db.select({
- id: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- finalQuoteAmount: biddingCompanies.finalQuoteAmount,
- awardRatio: biddingCompanies.awardRatio,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- contractType: biddingInfo[0].contractType,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isWinner, true)
- )
- )
-
- if (winnerCompanies.length === 0) {
- return { success: false, error: '낙찰된 업체가 없습니다.' }
- }
-
- // 일반/매각 입찰의 경우 비율 합계 100% 검증
- const contractType = biddingInfo[0].contractType
- if (contractType === 'general' || contractType === 'sale') {
- const totalRatio = winnerCompanies.reduce((sum, company) =>
- sum + (Number(company.awardRatio) || 0), 0)
-
- if (totalRatio !== 100) {
- return { success: false, error: `일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%` }
- }
- }
-
- // 2. 각 낙찰 업체의 입찰 품목 정보 조회
- const winnerDetails = []
-
- for (const winner of winnerCompanies) {
- // 업체의 입찰 품목 정보 조회
- const companyBids = await db.select({
- prItemId: companyPrItemBids.prItemId,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- bidUnitPrice: companyPrItemBids.bidUnitPrice,
- bidAmount: companyPrItemBids.bidAmount,
- currency: companyPrItemBids.currency,
- // PR 아이템 정보
- itemNumber: prItemsForBidding.itemNumber,
- itemInfo: prItemsForBidding.itemInfo,
- materialDescription: prItemsForBidding.materialDescription,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- })
- .from(companyPrItemBids)
- .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
- .where(eq(companyPrItemBids.biddingCompanyId, winner.id))
-
- // 발주비율에 따른 계산 (백분율을 실제 비율로 변환, 중량 제외)
- const awardRatio = (Number(winner.awardRatio) || 100) / 100
- const calculatedItems = companyBids.map(bid => {
- const originalQuantity = Number(bid.quantity) || 0
- const bidUnitPrice = Number(bid.bidUnitPrice) || 0
-
- // 발주비율에 따른 최종 수량 계산
- const finalQuantity = originalQuantity * awardRatio
- const finalWeight = 0 // 중량 제외
- const finalAmount = finalQuantity * bidUnitPrice
-
- return {
- ...bid,
- finalQuantity,
- finalWeight,
- finalAmount,
- awardRatio,
- }
- })
-
- // 업체 총 견적가 계산
- const totalFinalAmount = calculatedItems.reduce((sum, item) => sum + item.finalAmount, 0)
-
- winnerDetails.push({
- ...winner,
- items: calculatedItems,
- totalFinalAmount,
- awardRatio: Number(winner.awardRatio) || 1,
- })
- }
-
- return {
- success: true,
- data: winnerDetails
- }
-
- } catch (error) {
- console.error('Winner details 조회 실패:', error)
- return {
- success: false,
- error: '낙찰 업체 상세 정보 조회에 실패했습니다.'
- }
- }
-}
+"use server"
+
+import db from "@/db/db"
+import { eq, and } from "drizzle-orm"
+import {
+ biddings,
+ biddingCompanies,
+ prItemsForBidding,
+ companyPrItemBids,
+ vendors,
+ generalContracts,
+ generalContractItems,
+ biddingConditions,
+ biddingDocuments,
+ users
+} from "@/db/schema"
+import { createPurchaseOrder } from "@/lib/soap/ecc/send/create-po"
+import { getCurrentSAPDate } from "@/lib/soap/utils"
+import { generateContractNumber } from "@/lib/general-contracts/service"
+import { saveFile } from "@/lib/file-stroage"
+
+// TO Contract
+export async function transmitToContract(biddingId: number, userId: number) {
+ try {
+ // 1. 입찰 정보 조회 (단순 쿼리)
+ const bidding = await db.select()
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!bidding || bidding.length === 0) {
+ throw new Error("입찰 정보를 찾을 수 없습니다.")
+ }
+
+ const biddingData = bidding[0]
+
+ // 2. 입찰 조건 정보 조회
+ const biddingConditionData = await db.select()
+ .from(biddingConditions)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ .limit(1)
+
+ const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null
+
+ // 3. 낙찰된 업체들 조회 (biddingCompanies.id 포함)
+ const winnerCompaniesData = await db.select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ awardRatio: biddingCompanies.awardRatio,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isWinner, true)
+ )
+ )
+
+ // 상태 검증
+ if (biddingData.status !== 'vendor_selected') {
+ throw new Error("업체 선정이 완료되지 않은 입찰입니다.")
+ }
+
+ // 낙찰된 업체 검증
+ if (winnerCompaniesData.length === 0) {
+ throw new Error("낙찰된 업체가 없습니다.")
+ }
+
+ // 일반/매각 입찰의 경우 비율 합계 100% 검증
+ const contractType = biddingData.contractType
+ if (contractType === 'general' || contractType === 'sale') {
+ const totalRatio = winnerCompaniesData.reduce((sum, company) =>
+ sum + (Number(company.awardRatio) || 0), 0)
+
+ if (totalRatio !== 100) {
+ throw new Error(`일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%`)
+ }
+ }
+
+ for (const winnerCompany of winnerCompaniesData) {
+ // winnerCompany에서 직접 정보 사용
+ const awardRatio = (Number(winnerCompany.awardRatio) || 100) / 100
+ const biddingCompanyId = winnerCompany.id
+
+ // 현재 winnerCompany의 입찰 데이터 조회
+ const companyBids = await db.select({
+ prItemId: companyPrItemBids.prItemId,
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ currency: companyPrItemBids.currency,
+ // PR 아이템 정보도 함께 조회
+ itemNumber: prItemsForBidding.itemNumber,
+ itemInfo: prItemsForBidding.itemInfo,
+ materialDescription: prItemsForBidding.materialDescription,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ })
+ .from(companyPrItemBids)
+ .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
+ .where(eq(companyPrItemBids.biddingCompanyId, biddingCompanyId))
+
+ // 발주비율에 따른 최종 계약금액 계산
+ let totalContractAmount = 0
+ if (companyBids.length > 0) {
+ for (const bid of companyBids) {
+ const originalQuantity = Number(bid.quantity) || 0
+ const bidUnitPrice = Number(bid.bidUnitPrice) || 0
+ const finalQuantity = originalQuantity * awardRatio
+ const finalAmount = finalQuantity * bidUnitPrice
+ totalContractAmount += finalAmount
+ }
+ }
+
+ // 계약 번호 자동 생성 (실제 규칙에 맞게)
+ const contractNumber = await generateContractNumber(userId.toString(), biddingData.contractType)
+ console.log('Generated contractNumber:', contractNumber)
+
+ // general-contract 생성 (발주비율 계산된 최종 금액 사용)
+ const contractResult = await db.insert(generalContracts).values({
+ contractNumber,
+ revision: 0,
+ contractSourceType: 'bid', // 입찰에서 생성됨
+ status: 'Draft',
+ category: biddingData.contractType || 'general',
+ name: biddingData.title,
+ vendorId: winnerCompany.companyId,
+ linkedBidNumber: biddingData.biddingNumber,
+ contractAmount: totalContractAmount ? totalContractAmount.toString() as any : null, // 발주비율 계산된 최종 금액 사용
+ startDate: biddingData.contractStartDate || null,
+ endDate: biddingData.contractEndDate || null,
+ currency: biddingData.currency || 'KRW',
+ // 계약 조건 정보 추가
+ paymentTerm: biddingCondition?.paymentTerms || null,
+ taxType: biddingCondition?.taxConditions || 'V0',
+ deliveryTerm: biddingCondition?.incoterms || 'FOB',
+ shippingLocation: biddingCondition?.shippingPort || null,
+ dischargeLocation: biddingCondition?.destinationPort || null,
+ registeredById: userId,
+ lastUpdatedById: userId,
+ }).returning({ id: generalContracts.id })
+ console.log('contractResult', contractResult)
+ const contractId = contractResult[0].id
+
+ // 현재 winnerCompany의 품목정보 생성 (발주비율 적용)
+ if (companyBids.length > 0) {
+ console.log(`Creating ${companyBids.length} contract items for winner company ${winnerCompany.companyId} with award ratio ${awardRatio}`)
+ for (const bid of companyBids) {
+ // 발주비율에 따른 최종 수량 계산 (중량 제외)
+ const originalQuantity = Number(bid.quantity) || 0
+ const bidUnitPrice = Number(bid.bidUnitPrice) || 0
+
+ const finalQuantity = originalQuantity * awardRatio
+ const finalAmount = finalQuantity * bidUnitPrice
+
+ await db.insert(generalContractItems).values({
+ contractId: contractId,
+ itemCode: bid.itemNumber || '',
+ itemInfo: bid.itemInfo || '',
+ specification: bid.materialDescription || '',
+ quantity: finalQuantity || null,
+ quantityUnit: bid.quantityUnit || '',
+ totalWeight: null, // 중량 정보 제외
+ weightUnit: '', // 중량 단위 제외
+ contractDeliveryDate: bid.proposedDeliveryDate || null,
+ contractUnitPrice: bid.bidUnitPrice || null,
+ contractAmount: finalAmount ? finalAmount.toString() as any : null,
+ contractCurrency: bid.currency || biddingData.currency || 'KRW',
+ })
+ }
+ console.log(`Created ${companyBids.length} contract items for winner company ${winnerCompany.companyId}`)
+ } else {
+ console.log(`No bid data found for winner company ${winnerCompany.companyId}`)
+ }
+ }
+
+ return { success: true, message: `${winnerCompaniesData.length}개의 계약서가 생성되었습니다.` }
+
+ } catch (error) {
+ console.error('TO Contract 실패:', error)
+ throw new Error(error instanceof Error ? error.message : '계약서 생성에 실패했습니다.')
+ }
+}
+
+// TO PO
+export async function transmitToPO(biddingId: number) {
+ try {
+ // 1. 입찰 정보 조회
+ const biddingData = await db.select()
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!biddingData || biddingData.length === 0) {
+ throw new Error("입찰 정보를 찾을 수 없습니다.")
+ }
+
+ const bidding = biddingData[0]
+
+ if (bidding.status !== 'vendor_selected') {
+ throw new Error("업체 선정이 완료되지 않은 입찰입니다.")
+ }
+
+ // 2. 입찰 조건 정보 조회
+ const biddingConditionData = await db.select()
+ .from(biddingConditions)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ .limit(1)
+
+ const biddingCondition = biddingConditionData.length > 0 ? biddingConditionData[0] : null
+
+ // 3. 낙찰된 업체들 조회 (발주비율 포함)
+ const winnerCompaniesRaw = await db.select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ awardRatio: biddingCompanies.awardRatio,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isWinner, true)
+ )
+ )
+
+ if (winnerCompaniesRaw.length === 0) {
+ throw new Error("낙찰된 업체가 없습니다.")
+ }
+
+ // 일반/매각 입찰의 경우 비율 합계 100% 검증
+ const contractType = bidding.contractType
+ if (contractType === 'general' || contractType === 'sale') {
+ const totalRatio = winnerCompaniesRaw.reduce((sum, company) =>
+ sum + (Number(company.awardRatio) || 0), 0)
+
+ if (totalRatio !== 100) {
+ throw new Error(`일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%`)
+ }
+ }
+
+ // 4. 낙찰된 업체들의 입찰 데이터 조회 (발주비율 적용)
+ type POItem = {
+ prItemId: number
+ proposedDeliveryDate: string | null
+ bidUnitPrice: string | null
+ bidAmount: string | null
+ currency: string | null
+ itemNumber: string | null
+ itemInfo: string | null
+ materialDescription: string | null
+ quantity: string | null
+ quantityUnit: string | null
+ finalQuantity: number
+ finalAmount: number
+ awardRatio: number
+ vendorCode: string | null
+ vendorName: string | null
+ companyId: number
+ }
+ const poItems: POItem[] = []
+ for (const winner of winnerCompaniesRaw) {
+ const awardRatio = (Number(winner.awardRatio) || 100) / 100
+
+ const companyBids = await db.select({
+ prItemId: companyPrItemBids.prItemId,
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ currency: companyPrItemBids.currency,
+ // PR 아이템 정보
+ itemNumber: prItemsForBidding.itemNumber,
+ itemInfo: prItemsForBidding.itemInfo,
+ materialDescription: prItemsForBidding.materialDescription,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ })
+ .from(companyPrItemBids)
+ .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
+ .where(eq(companyPrItemBids.biddingCompanyId, winner.id))
+
+ // 발주비율 적용하여 PO 아이템 생성 (중량 제외)
+ for (const bid of companyBids) {
+ const originalQuantity = Number(bid.quantity) || 0
+ const bidUnitPrice = Number(bid.bidUnitPrice) || 0
+
+ const finalQuantity = originalQuantity * awardRatio
+ const finalAmount = finalQuantity * bidUnitPrice
+
+ poItems.push({
+ ...bid,
+ finalQuantity,
+ finalAmount,
+ awardRatio,
+ vendorCode: winner.vendorCode,
+ vendorName: winner.vendorName,
+ companyId: winner.companyId,
+ } as POItem)
+ }
+ }
+
+ // 5. PO 데이터 구성 (bidding condition 정보와 발주비율 적용된 데이터 사용)
+ const poData = {
+ T_Bidding_HEADER: winnerCompaniesRaw.map((company) => ({
+ ANFNR: bidding.biddingNumber,
+ LIFNR: company.vendorCode || `VENDOR${company.companyId}`,
+ ZPROC_IND: 'A', // 구매 처리 상태
+ ANGNR: bidding.biddingNumber,
+ WAERS: bidding.currency || 'KRW',
+ ZTERM: biddingCondition?.paymentTerms || '0001', // 지급조건
+ INCO1: biddingCondition?.incoterms || 'FOB', // Incoterms
+ INCO2: biddingCondition?.destinationPort || biddingCondition?.shippingPort || 'Seoul, Korea',
+ MWSKZ: biddingCondition?.taxConditions || 'V0', // 세금 코드
+ LANDS: 'KR',
+ ZRCV_DT: getCurrentSAPDate(),
+ ZATTEN_IND: 'Y',
+ IHRAN: getCurrentSAPDate(),
+ TEXT: `PO from Bidding: ${bidding.title}`,
+ })),
+ T_Bidding_ITEM: poItems.map((item, index) => ({
+ ANFNR: bidding.biddingNumber,
+ ANFPS: (index + 1).toString().padStart(5, '0'),
+ LIFNR: item.vendorCode || `VENDOR${item.companyId}`,
+ NETPR: item.bidUnitPrice?.toString() || '0',
+ PEINH: '1',
+ BPRME: item.quantityUnit || 'EA',
+ NETWR: item.finalAmount?.toString() || '0',
+ BRTWR: (Number(item.finalAmount || 0) * 1.1).toString(), // 10% 부가세 가정
+ LFDAT: item.proposedDeliveryDate ? new Date(item.proposedDeliveryDate).toISOString().split('T')[0] : getCurrentSAPDate(),
+ })),
+ T_PR_RETURN: [{
+ ANFNR: bidding.biddingNumber,
+ ANFPS: '00001',
+ EBELN: `PR${bidding.biddingNumber}`,
+ EBELP: '00001',
+ MSGTY: 'S',
+ MSGTXT: 'Success'
+ }]
+ }
+
+ // 3. SAP으로 PO 전송
+ console.log('SAP으로 PO 전송할 poData', poData)
+ const result = await createPurchaseOrder(poData)
+
+ if (!result.success) {
+ throw new Error(result.message)
+ }
+
+ return { success: true, message: result.message }
+
+ } catch (error) {
+ console.error('TO PO 실패:', error)
+ throw new Error(error instanceof Error ? error.message : 'PO 전송에 실패했습니다.')
+ }
+}
+
+// 낙찰된 업체들의 상세 정보 조회 (발주비율에 따른 계산 포함)
+export async function getWinnerDetails(biddingId: number) {
+ try {
+ // 1. 입찰 정보 조회 (contractType 포함)
+ const biddingInfo = await db.select({
+ contractType: biddings.contractType,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!biddingInfo || biddingInfo.length === 0) {
+ return { success: false, error: '입찰 정보를 찾을 수 없습니다.' }
+ }
+
+ // 2. 낙찰된 업체들 조회
+ const winnerCompanies = await db.select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ finalQuoteAmount: biddingCompanies.finalQuoteAmount,
+ awardRatio: biddingCompanies.awardRatio,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ contractType: biddingInfo[0].contractType,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isWinner, true)
+ )
+ )
+
+ if (winnerCompanies.length === 0) {
+ return { success: false, error: '낙찰된 업체가 없습니다.' }
+ }
+
+ // 일반/매각 입찰의 경우 비율 합계 100% 검증
+ const contractType = biddingInfo[0].contractType
+ if (contractType === 'general' || contractType === 'sale') {
+ const totalRatio = winnerCompanies.reduce((sum, company) =>
+ sum + (Number(company.awardRatio) || 0), 0)
+
+ if (totalRatio !== 100) {
+ return { success: false, error: `일반/매각 입찰의 경우 비율 합계가 100%여야 합니다. 현재 합계: ${totalRatio}%` }
+ }
+ }
+
+ // 2. 각 낙찰 업체의 입찰 품목 정보 조회
+ const winnerDetails = []
+
+ for (const winner of winnerCompanies) {
+ // 업체의 입찰 품목 정보 조회
+ const companyBids = await db.select({
+ prItemId: companyPrItemBids.prItemId,
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ currency: companyPrItemBids.currency,
+ // PR 아이템 정보
+ itemNumber: prItemsForBidding.itemNumber,
+ itemInfo: prItemsForBidding.itemInfo,
+ materialDescription: prItemsForBidding.materialDescription,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ })
+ .from(companyPrItemBids)
+ .leftJoin(prItemsForBidding, eq(companyPrItemBids.prItemId, prItemsForBidding.id))
+ .where(eq(companyPrItemBids.biddingCompanyId, winner.id))
+
+ // 발주비율에 따른 계산 (백분율을 실제 비율로 변환, 중량 제외)
+ const awardRatio = (Number(winner.awardRatio) || 100) / 100
+ const calculatedItems = companyBids.map(bid => {
+ const originalQuantity = Number(bid.quantity) || 0
+ const bidUnitPrice = Number(bid.bidUnitPrice) || 0
+
+ // 발주비율에 따른 최종 수량 계산
+ const finalQuantity = originalQuantity * awardRatio
+ const finalWeight = 0 // 중량 제외
+ const finalAmount = finalQuantity * bidUnitPrice
+
+ return {
+ ...bid,
+ finalQuantity,
+ finalWeight,
+ finalAmount,
+ awardRatio,
+ }
+ })
+
+ // 업체 총 견적가 계산
+ const totalFinalAmount = calculatedItems.reduce((sum, item) => sum + item.finalAmount, 0)
+
+ winnerDetails.push({
+ ...winner,
+ items: calculatedItems,
+ totalFinalAmount,
+ awardRatio: Number(winner.awardRatio) || 1,
+ })
+ }
+
+ return {
+ success: true,
+ data: winnerDetails
+ }
+
+ } catch (error) {
+ console.error('Winner details 조회 실패:', error)
+ return {
+ success: false,
+ error: '낙찰 업체 상세 정보 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 폐찰하기 액션
+export async function bidClosureAction(
+ biddingId: number,
+ formData: {
+ description: string
+ files: File[]
+ },
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ return await db.transaction(async (tx) => {
+ // 1. 입찰 정보 확인
+ const [existingBidding] = await tx
+ .select()
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!existingBidding) {
+ return {
+ success: false,
+ error: '입찰 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 2. 유찰 상태인지 확인
+ if (existingBidding.status !== 'bidding_disposal') {
+ return {
+ success: false,
+ error: '유찰 상태인 입찰만 폐찰할 수 있습니다.'
+ }
+ }
+
+ // 3. 입찰 상태를 폐찰로 변경하고 설명 저장
+ await tx
+ .update(biddings)
+ .set({
+ status: 'bid_closure',
+ description: formData.description,
+ updatedAt: new Date(),
+ updatedBy: userName,
+ })
+ .where(eq(biddings.id, biddingId))
+
+ // 4. 첨부파일들 저장 (evaluation_doc로 저장)
+ if (formData.files && formData.files.length > 0) {
+ for (const file of formData.files) {
+ try {
+ const saveResult = await saveFile({
+ file,
+ directory: `biddings/${biddingId}/closure-documents`,
+ originalName: file.name,
+ userId
+ })
+
+ if (saveResult.success) {
+ await tx.insert(biddingDocuments).values({
+ biddingId,
+ documentType: 'evaluation_doc',
+ fileName: saveResult.fileName!,
+ originalFileName: saveResult.originalName!,
+ fileSize: saveResult.fileSize!,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!,
+ title: `폐찰 문서 - ${file.name}`,
+ description: formData.description,
+ isPublic: false,
+ isRequired: false,
+ uploadedBy: userName,
+ })
+ } else {
+ console.error(`Failed to save closure file: ${file.name}`, saveResult.error)
+ }
+ } catch (error) {
+ console.error(`Error saving closure file: ${file.name}`, error)
+ }
+ }
+ }
+
+ return {
+ success: true,
+ message: '폐찰이 완료되었습니다.'
+ }
+ })
+
+ } catch (error) {
+ console.error('폐찰 실패:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '폐찰 중 오류가 발생했습니다.'
+ }
+ }
+}
+
+// 사용자 이름 조회 헬퍼 함수
+async function getUserNameById(userId: string): Promise<string> {
+ try {
+ const user = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1)
+
+ return user[0]?.name || userId
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId
+ }
+}
diff --git a/lib/bidding/bidding-notice-editor.tsx b/lib/bidding/bidding-notice-editor.tsx
index 03b993b9..8d0f1e35 100644
--- a/lib/bidding/bidding-notice-editor.tsx
+++ b/lib/bidding/bidding-notice-editor.tsx
@@ -8,15 +8,30 @@ 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 { saveBiddingNoticeTemplate, saveBiddingNotice } from './service'
import TiptapEditor from '@/components/qna/tiptap-editor'
interface BiddingNoticeEditorProps {
initialData: BiddingNoticeTemplate | null
+ biddingId?: number // 입찰 ID (있으면 일반 입찰공고, 없으면 템플릿)
+ templateType?: string // 템플릿 타입 (템플릿 저장 시 사용)
+ onSaveSuccess?: () => void // 저장 성공 시 콜백
+ onTemplateUpdate?: (template: BiddingNoticeTemplate) => void // 템플릿 업데이트 콜백
}
-export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) {
- const [title, setTitle] = useState(initialData?.title || '표준 입찰공고문')
+export function BiddingNoticeEditor({ initialData, biddingId, templateType, onSaveSuccess, onTemplateUpdate }: BiddingNoticeEditorProps) {
+ const getDefaultTitle = (type?: string) => {
+ switch (type) {
+ case 'facility':
+ return '시설재 입찰공고문'
+ case 'unit_price':
+ return '단가계약 입찰공고문'
+ default:
+ return '표준 입찰공고문'
+ }
+ }
+
+ const [title, setTitle] = useState(initialData?.title || getDefaultTitle(templateType))
const [content, setContent] = useState(initialData?.content || getDefaultTemplate())
const [isPending, startTransition] = useTransition()
const { toast } = useToast()
@@ -43,12 +58,47 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) {
startTransition(async () => {
try {
- await saveBiddingNoticeTemplate({ title, content })
- toast({
- title: '성공',
- description: '입찰공고문 템플릿이 저장되었습니다.',
- })
+ if (biddingId) {
+ // 일반 입찰공고 저장
+ await saveBiddingNotice(biddingId, { title, content })
+ toast({
+ title: '성공',
+ description: '입찰공고문이 저장되었습니다.',
+ })
+ } else {
+ // 템플릿 저장
+ if (!templateType) {
+ toast({
+ title: '오류',
+ description: '템플릿 타입이 지정되지 않았습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ const savedTemplate = await saveBiddingNoticeTemplate({ title, content, type: templateType })
+ toast({
+ title: '성공',
+ description: '입찰공고문 템플릿이 저장되었습니다.',
+ })
+
+ // 템플릿 업데이트 콜백 호출
+ if (onTemplateUpdate && savedTemplate) {
+ // 저장된 템플릿 데이터를 가져와서 콜백 호출
+ const updatedTemplate = {
+ ...initialData,
+ title,
+ content,
+ type: templateType,
+ updatedAt: new Date(),
+ } as BiddingNoticeTemplate
+ onTemplateUpdate(updatedTemplate)
+ }
+ }
router.refresh()
+
+ // 저장 성공 시 콜백 호출
+ onSaveSuccess?.()
} catch (error) {
toast({
title: '오류',
@@ -59,16 +109,16 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) {
})
}
- const handleReset = () => {
- if (confirm('기본 템플릿으로 초기화하시겠습니까? 현재 내용은 삭제됩니다.')) {
- setTitle('표준 입찰공고문')
- setContent(getDefaultTemplate())
- toast({
- title: '초기화 완료',
- description: '기본 템플릿으로 초기화되었습니다.',
- })
- }
- }
+ // const handleReset = () => {
+ // if (confirm('기본 템플릿으로 초기화하시겠습니까? 현재 내용은 삭제됩니다.')) {
+ // setTitle(getDefaultTitle(templateType))
+ // setContent(getDefaultTemplate())
+ // toast({
+ // title: '초기화 완료',
+ // description: '기본 템플릿으로 초기화되었습니다.',
+ // })
+ // }
+ // }
return (
<div className="space-y-6">
@@ -117,13 +167,13 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) {
)}
</Button>
- <Button
+ {/* <Button
variant="outline"
onClick={handleReset}
disabled={isPending}
>
기본 템플릿으로 초기화
- </Button>
+ </Button> */}
{initialData && (
<div className="ml-auto text-sm text-muted-foreground">
@@ -131,15 +181,6 @@ export function BiddingNoticeEditor({ initialData }: BiddingNoticeEditorProps) {
</div>
)}
</div>
-
- {/* 미리보기 힌트 */}
- <div className="bg-muted/50 p-4 rounded-lg">
- <p className="text-sm text-muted-foreground">
- <strong>💡 사용 팁:</strong>
- 이 템플릿은 실제 입찰 공고 작성 시 기본값으로 사용됩니다.
- 회사 정보, 표준 조건, 서식 등을 미리 작성해두면 편리합니다.
- </p>
- </div>
</div>
)
}
diff --git a/lib/bidding/bidding-notice-template-manager.tsx b/lib/bidding/bidding-notice-template-manager.tsx
new file mode 100644
index 00000000..3426020f
--- /dev/null
+++ b/lib/bidding/bidding-notice-template-manager.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import { useState } from 'react'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { BiddingNoticeEditor } from './bidding-notice-editor'
+import { BiddingNoticeTemplate } from '@/db/schema/bidding'
+import { biddingNoticeTypeLabels } from '@/db/schema/bidding'
+
+interface BiddingNoticeTemplateManagerProps {
+ initialTemplates: Record<string, BiddingNoticeTemplate>
+}
+
+export function BiddingNoticeTemplateManager({ initialTemplates }: BiddingNoticeTemplateManagerProps) {
+ const [activeTab, setActiveTab] = useState('standard')
+ const [templates, setTemplates] = useState(initialTemplates)
+
+ const handleTemplateUpdate = (type: string, template: BiddingNoticeTemplate) => {
+ setTemplates(prev => ({
+ ...prev,
+ [type]: template
+ }))
+ }
+
+ const templateTypes = [
+ { key: 'standard', label: biddingNoticeTypeLabels.standard },
+ { key: 'facility', label: biddingNoticeTypeLabels.facility },
+ { key: 'unit_price', label: biddingNoticeTypeLabels.unit_price }
+ ]
+
+ return (
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList className="grid w-full grid-cols-3">
+ {templateTypes.map(({ key, label }) => (
+ <TabsTrigger key={key} value={key}>
+ {label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ {templateTypes.map(({ key, label }) => (
+ <TabsContent key={key} value={key}>
+ <Card>
+ <CardHeader>
+ <CardTitle>{label} 입찰공고문 템플릿</CardTitle>
+ <CardDescription>
+ {label} 타입의 입찰공고문 템플릿을 작성하고 관리할 수 있습니다.
+ 이 템플릿은 실제 입찰 공고 작성 시 기본 양식으로 사용됩니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <BiddingNoticeEditor
+ initialData={templates[key]}
+ templateType={key}
+ onTemplateUpdate={(template) => handleTemplateUpdate(key, template)}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+ ))}
+ </Tabs>
+ )
+}
diff --git a/lib/bidding/detail/bidding-actions.ts b/lib/bidding/detail/bidding-actions.ts
new file mode 100644
index 00000000..70bba1c3
--- /dev/null
+++ b/lib/bidding/detail/bidding-actions.ts
@@ -0,0 +1,227 @@
+'use server'
+
+import db from '@/db/db'
+import { biddings, biddingCompanies, companyPrItemBids } from '@/db/schema/bidding'
+import { eq, and } from 'drizzle-orm'
+import { revalidateTag, revalidatePath } from 'next/cache'
+import { users } from '@/db/schema'
+
+// userId를 user.name으로 변환하는 유틸리티 함수
+async function getUserNameById(userId: string): Promise<string> {
+ try {
+ const user = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1)
+
+ return user[0]?.name || userId
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId
+ }
+}
+
+// 응찰 취소 서버 액션 (최종제출이 아닌 경우만 가능)
+export async function cancelBiddingResponse(
+ biddingCompanyId: number,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ return await db.transaction(async (tx) => {
+ // 1. 현재 상태 확인 (최종제출 여부)
+ const [company] = await tx
+ .select({
+ isFinalSubmission: biddingCompanies.isFinalSubmission,
+ biddingId: biddingCompanies.biddingId,
+ })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ .limit(1)
+
+ if (!company) {
+ return {
+ success: false,
+ error: '업체 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 최종제출한 경우 취소 불가
+ if (company.isFinalSubmission) {
+ return {
+ success: false,
+ error: '최종 제출된 응찰은 취소할 수 없습니다.'
+ }
+ }
+
+ // 2. 응찰 데이터 초기화
+ await tx
+ .update(biddingCompanies)
+ .set({
+ finalQuoteAmount: null,
+ finalQuoteSubmittedAt: null,
+ isFinalSubmission: false,
+ invitationStatus: 'bidding_cancelled', // 응찰 취소 상태
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ // 3. 품목별 견적 삭제 (본입찰 데이터)
+ await tx
+ .delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, false)
+ )
+ )
+
+ // 캐시 무효화
+ revalidateTag(`bidding-${company.biddingId}`)
+ revalidateTag('quotation-vendors')
+ revalidateTag('quotation-details')
+ revalidatePath(`/partners/bid/${company.biddingId}`)
+
+ return {
+ success: true,
+ message: '응찰이 취소되었습니다.'
+ }
+ })
+ } catch (error) {
+ console.error('Failed to cancel bidding response:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '응찰 취소에 실패했습니다.'
+ }
+ }
+}
+
+// 모든 벤더가 최종제출했는지 확인
+export async function checkAllVendorsFinalSubmitted(biddingId: number) {
+ try {
+ const companies = await db
+ .select({
+ id: biddingCompanies.id,
+ isFinalSubmission: biddingCompanies.isFinalSubmission,
+ invitationStatus: biddingCompanies.invitationStatus,
+ })
+ .from(biddingCompanies)
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isBiddingInvited, true) // 본입찰 초대된 업체만
+ )
+ )
+
+ // 초대된 업체가 없으면 false
+ if (companies.length === 0) {
+ return {
+ allSubmitted: false,
+ totalCompanies: 0,
+ submittedCompanies: 0
+ }
+ }
+
+ // 모든 업체가 최종제출했는지 확인
+ const submittedCompanies = companies.filter(c => c.isFinalSubmission).length
+ const allSubmitted = companies.every(c => c.isFinalSubmission)
+
+ return {
+ allSubmitted,
+ totalCompanies: companies.length,
+ submittedCompanies
+ }
+ } catch (error) {
+ console.error('Failed to check all vendors final submitted:', error)
+ return {
+ allSubmitted: false,
+ totalCompanies: 0,
+ submittedCompanies: 0
+ }
+ }
+}
+
+// 개찰 서버 액션 (조기개찰/개찰 구분)
+export async function performBidOpening(
+ biddingId: number,
+ userId: string,
+ isEarly: boolean = false // 조기개찰 여부
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ return await db.transaction(async (tx) => {
+ // 1. 입찰 정보 조회
+ const [bidding] = await tx
+ .select({
+ id: biddings.id,
+ status: biddings.status,
+ submissionEndDate: biddings.submissionEndDate,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!bidding) {
+ return {
+ success: false,
+ error: '입찰 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 2. 개찰 가능 여부 확인 (evaluation_of_bidding 상태에서만)
+ if (bidding.status !== 'evaluation_of_bidding') {
+ return {
+ success: false,
+ error: '입찰평가중 상태에서만 개찰할 수 있습니다.'
+ }
+ }
+
+ // 3. 모든 벤더가 최종제출했는지 확인
+ const checkResult = await checkAllVendorsFinalSubmitted(biddingId)
+ if (!checkResult.allSubmitted) {
+ return {
+ success: false,
+ error: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${checkResult.submittedCompanies}/${checkResult.totalCompanies})`
+ }
+ }
+
+ // 4. 조기개찰 여부 결정
+ const now = new Date()
+ const submissionEndDate = bidding.submissionEndDate ? new Date(bidding.submissionEndDate) : null
+ const isBeforeDeadline = submissionEndDate && now < submissionEndDate
+
+ // 마감일 전이면 조기개찰, 마감일 후면 일반 개찰
+ const newStatus = (isEarly || isBeforeDeadline) ? 'early_bid_opening' : 'bid_opening'
+
+ // 5. 입찰 상태 변경
+ await tx
+ .update(biddings)
+ .set({
+ status: newStatus,
+ updatedAt: new Date()
+ })
+ .where(eq(biddings.id, biddingId))
+
+ // 캐시 무효화
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-detail')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+
+ return {
+ success: true,
+ message: `${newStatus === 'early_bid_opening' ? '조기개찰' : '개찰'}이 완료되었습니다.`,
+ status: newStatus
+ }
+ })
+ } catch (error) {
+ console.error('Failed to perform bid opening:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '개찰에 실패했습니다.'
+ }
+ }
+}
+
diff --git a/lib/bidding/detail/service.ts b/lib/bidding/detail/service.ts
index 404bc3cd..d58ded8e 100644
--- a/lib/bidding/detail/service.ts
+++ b/lib/bidding/detail/service.ts
@@ -81,6 +81,7 @@ export interface QuotationVendor {
vendorId: number
vendorName: string
vendorCode: string
+ vendorEmail?: string // 벤더의 기본 이메일
contactPerson: string
contactEmail: string
contactPhone: string
@@ -90,7 +91,7 @@ export interface QuotationVendor {
isWinner: boolean | null // 낙찰여부 (null: 미정, true: 낙찰, false: 탈락)
awardRatio: number | null // 발주비율
isBiddingParticipated: boolean | null // 본입찰 참여여부
- status: 'pending' | 'submitted' | 'selected' | 'rejected'
+ invitationStatus: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
documents: Array<{
id: number
fileName: string
@@ -241,6 +242,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
vendorId: biddingCompanies.companyId,
vendorName: vendors.vendorName,
vendorCode: vendors.vendorCode,
+ vendorEmail: vendors.email, // 벤더의 기본 이메일
contactPerson: biddingCompanies.contactPerson,
contactEmail: biddingCompanies.contactEmail,
contactPhone: biddingCompanies.contactPhone,
@@ -251,12 +253,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
// awardRatio: sql<number>`CASE WHEN ${biddingCompanies.isWinner} THEN 100 ELSE 0 END`,
awardRatio: biddingCompanies.awardRatio,
isBiddingParticipated: biddingCompanies.isBiddingParticipated,
- status: sql<string>`CASE
- WHEN ${biddingCompanies.isWinner} THEN 'selected'
- WHEN ${biddingCompanies.finalQuoteSubmittedAt} IS NOT NULL THEN 'submitted'
- WHEN ${biddingCompanies.respondedAt} IS NOT NULL THEN 'submitted'
- ELSE 'pending'
- END`,
+ invitationStatus: biddingCompanies.invitationStatus,
})
.from(biddingCompanies)
.leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
@@ -272,6 +269,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
vendorId: vendor.vendorId,
vendorName: vendor.vendorName || `Vendor ${vendor.vendorId}`,
vendorCode: vendor.vendorCode || '',
+ vendorEmail: vendor.vendorEmail || '', // 벤더의 기본 이메일
contactPerson: vendor.contactPerson || '',
contactEmail: vendor.contactEmail || '',
contactPhone: vendor.contactPhone || '',
@@ -281,7 +279,7 @@ export async function getQuotationVendors(biddingId: number): Promise<QuotationV
isWinner: vendor.isWinner,
awardRatio: vendor.awardRatio ? Number(vendor.awardRatio) : null,
isBiddingParticipated: vendor.isBiddingParticipated,
- status: vendor.status as 'pending' | 'submitted' | 'selected' | 'rejected',
+ invitationStatus: vendor.invitationStatus,
documents: [], // 빈 배열로 초기화
}))
} catch (error) {
@@ -622,7 +620,8 @@ export async function updateBiddingDetailVendor(
// 본입찰용 업체 추가
export async function createBiddingDetailVendor(
biddingId: number,
- vendorId: number
+ vendorId: number,
+ isPriceAdjustmentApplicableQuestion?: boolean
) {
try {
const result = await db.transaction(async (tx) => {
@@ -630,9 +629,10 @@ export async function createBiddingDetailVendor(
const biddingCompanyResult = await tx.insert(biddingCompanies).values({
biddingId: biddingId,
companyId: vendorId,
- invitationStatus: 'pending',
+ invitationStatus: 'pending', // 초대 대기
isPreQuoteSelected: true, // 본입찰 등록 기본값
isWinner: null, // 미정 상태로 초기화 0916
+ isPriceAdjustmentApplicableQuestion: isPriceAdjustmentApplicableQuestion ?? false,
createdAt: new Date(),
updatedAt: new Date(),
}).returning({ id: biddingCompanies.id })
@@ -730,9 +730,8 @@ export async function markAsDisposal(biddingId: number, userId: string) {
itemName: bidding.itemName,
biddingType: bidding.biddingType,
processedDate: new Date().toLocaleDateString('ko-KR'),
- managerName: bidding.managerName,
- managerEmail: bidding.managerEmail,
- managerPhone: bidding.managerPhone,
+ bidPicName: bidding.bidPicName,
+ supplyPicName: bidding.supplyPicName,
language: 'ko'
}
})
@@ -807,7 +806,7 @@ export async function registerBidding(biddingId: number, userId: string) {
.update(biddingCompanies)
.set({
isBiddingInvited: true,
- invitationStatus: 'sent',
+ invitationStatus: 'bidding_sent', // 입찰 초대 발송
updatedAt: new Date()
})
.where(and(
@@ -834,9 +833,8 @@ export async function registerBidding(biddingId: number, userId: string) {
submissionStartDate: bidding.submissionStartDate,
submissionEndDate: bidding.submissionEndDate,
biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`,
- managerName: bidding.managerName,
- managerEmail: bidding.managerEmail,
- managerPhone: bidding.managerPhone,
+ bidPicName: bidding.bidPicName,
+ supplyPicName: bidding.supplyPicName,
language: 'ko'
}
})
@@ -945,9 +943,8 @@ export async function createRebidding(biddingId: number, userId: string) {
submissionStartDate: originalBidding.submissionStartDate,
submissionEndDate: originalBidding.submissionEndDate,
biddingUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/partners/bid/${biddingId}`,
- managerName: originalBidding.managerName,
- managerEmail: originalBidding.managerEmail,
- managerPhone: originalBidding.managerPhone,
+ bidPicName: originalBidding.bidPicName,
+ supplyPicName: originalBidding.supplyPicName,
language: 'ko'
}
})
@@ -1521,6 +1518,7 @@ export interface PartnersBiddingListItem {
// biddings 정보
biddingId: number
biddingNumber: string
+ originalBiddingNumber: string | null // 원입찰번호
revision: number | null
projectName: string
itemName: string
@@ -1533,9 +1531,10 @@ export interface PartnersBiddingListItem {
submissionStartDate: Date | null
submissionEndDate: Date | null
status: string
- managerName: string | null
- managerEmail: string | null
- managerPhone: string | null
+ // 입찰담당자
+ bidPicName: string | null
+ // 조달담당자
+ supplyPicName: string | null
currency: string
budget: number | null
isUrgent: boolean | null // 긴급여부
@@ -1572,6 +1571,7 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
// biddings 정보
biddingId: biddings.id,
biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber, // 원입찰번호
revision: biddings.revision,
projectName: biddings.projectName,
itemName: biddings.itemName,
@@ -1584,9 +1584,12 @@ export async function getBiddingListForPartners(companyId: number): Promise<Part
submissionStartDate: biddings.submissionStartDate,
submissionEndDate: biddings.submissionEndDate,
status: biddings.status,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
+ // 기존 담당자 필드 (하위호환성 유지)
+
+ // 입찰담당자
+ bidPicName: biddings.bidPicName,
+ // 조달담당자
+ supplyPicName: biddings.supplyPicName,
currency: biddings.currency,
budget: biddings.budget,
isUrgent: biddings.isUrgent,
@@ -1635,7 +1638,6 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
itemName: biddings.itemName,
title: biddings.title,
description: biddings.description,
- content: biddings.content,
// 계약 정보
contractType: biddings.contractType,
@@ -1659,15 +1661,15 @@ export async function getBiddingDetailsForPartners(biddingId: number, companyId:
// 상태 및 담당자
status: biddings.status,
isUrgent: biddings.isUrgent,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
// 협력업체 특정 정보
biddingCompanyId: biddingCompanies.id,
invitationStatus: biddingCompanies.invitationStatus,
finalQuoteAmount: biddingCompanies.finalQuoteAmount,
finalQuoteSubmittedAt: biddingCompanies.finalQuoteSubmittedAt,
+ isFinalSubmission: biddingCompanies.isFinalSubmission,
isWinner: biddingCompanies.isWinner,
isAttendingMeeting: biddingCompanies.isAttendingMeeting,
isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
@@ -1718,6 +1720,7 @@ export async function submitPartnerResponse(
sparePartResponse?: string
additionalProposals?: string
finalQuoteAmount?: number
+ isFinalSubmission?: boolean // 최종제출 여부 추가
prItemQuotations?: Array<{
prItemId: number
bidUnitPrice: number
@@ -1851,7 +1854,15 @@ export async function submitPartnerResponse(
if (response.finalQuoteAmount !== undefined) {
companyUpdateData.finalQuoteAmount = response.finalQuoteAmount
companyUpdateData.finalQuoteSubmittedAt = new Date()
- companyUpdateData.invitationStatus = 'submitted'
+
+ // 최종제출 여부에 따라 상태 및 플래그 설정
+ if (response.isFinalSubmission) {
+ companyUpdateData.isFinalSubmission = true
+ companyUpdateData.invitationStatus = 'bidding_submitted' // 응찰 완료
+ } else {
+ companyUpdateData.isFinalSubmission = false
+ // 임시저장: invitationStatus는 변경하지 않음 (bidding_accepted 유지)
+ }
}
await tx
@@ -1868,8 +1879,8 @@ export async function submitPartnerResponse(
const biddingId = biddingCompanyInfo[0]?.biddingId
- // 응찰 제출 시 입찰 상태를 평가중으로 변경 (bidding_opened 상태에서만)
- if (biddingId && response.finalQuoteAmount !== undefined) {
+ // 최종제출인 경우, 입찰 상태를 평가중으로 변경 (bidding_opened 상태에서만)
+ if (biddingId && response.finalQuoteAmount !== undefined && response.isFinalSubmission) {
await tx
.update(biddings)
.set({
@@ -2023,14 +2034,15 @@ export async function updatePartnerAttendance(
})
.where(eq(biddingCompanies.id, biddingCompanyId))
- // 참석하는 경우, 사양설명회 담당자에게 이메일 발송을 위한 정보 반환
+ // 참석하는 경우, 사양설명회 담당자(contactEmail)에 이메일 발송을 위한 정보 반환
if (attendanceData.isAttending) {
+ // 입찰 + 사양설명회 + 업체 정보 불러오기
const biddingInfo = await tx
.select({
biddingId: biddingCompanies.biddingId,
companyId: biddingCompanies.companyId,
- managerEmail: biddings.managerEmail,
- managerName: biddings.managerName,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
title: biddings.title,
biddingNumber: biddings.biddingNumber,
})
@@ -2040,7 +2052,7 @@ export async function updatePartnerAttendance(
.limit(1)
if (biddingInfo.length > 0) {
- // 협력업체 정보 조회
+ // 업체 정보
const companyInfo = await tx
.select({
vendorName: vendors.vendorName,
@@ -2051,37 +2063,59 @@ export async function updatePartnerAttendance(
const companyName = companyInfo.length > 0 ? companyInfo[0].vendorName : '알 수 없음'
- // 메일 발송 (템플릿 사용)
- try {
- const { sendEmail } = await import('@/lib/mail/sendEmail')
-
- await sendEmail({
- to: biddingInfo[0].managerEmail,
- template: 'specification-meeting-attendance',
- context: {
- biddingNumber: biddingInfo[0].biddingNumber,
- title: biddingInfo[0].title,
- companyName: companyName,
- attendeeCount: attendanceData.attendeeCount,
- representativeName: attendanceData.representativeName,
- representativePhone: attendanceData.representativePhone,
- managerName: biddingInfo[0].managerName,
- managerEmail: biddingInfo[0].managerEmail,
- currentYear: new Date().getFullYear(),
- language: 'ko'
- }
+ // 사양설명회 상세 정보(담당자 email 포함)
+ const specificationMeetingInfo = await tx
+ .select({
+ contactEmail: specificationMeetings.contactEmail,
+ meetingDate: specificationMeetings.meetingDate,
+ meetingTime: specificationMeetings.meetingTime,
+ location: specificationMeetings.location,
})
-
- console.log(`사양설명회 참석 알림 메일 발송 완료: ${biddingInfo[0].managerEmail}`)
- } catch (emailError) {
- console.error('메일 발송 실패:', emailError)
- // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingInfo[0].biddingId))
+ .limit(1)
+
+ const contactEmail = specificationMeetingInfo.length > 0 ? specificationMeetingInfo[0].contactEmail : null
+
+ // 메일 발송 (템플릿 사용)
+ if (contactEmail) {
+ try {
+ const { sendEmail } = await import('@/lib/mail/sendEmail')
+
+ await sendEmail({
+ to: contactEmail,
+ template: 'specification-meeting-attendance',
+ context: {
+ biddingNumber: biddingInfo[0].biddingNumber,
+ title: biddingInfo[0].title,
+ companyName: companyName,
+ attendeeCount: attendanceData.attendeeCount,
+ representativeName: attendanceData.representativeName,
+ representativePhone: attendanceData.representativePhone,
+ bidPicName: biddingInfo[0].bidPicName,
+ supplyPicName: biddingInfo[0].supplyPicName,
+ meetingDate: specificationMeetingInfo[0]?.meetingDate,
+ meetingTime: specificationMeetingInfo[0]?.meetingTime,
+ location: specificationMeetingInfo[0]?.location,
+ contactEmail: contactEmail,
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ }
+ })
+
+ console.log(`사양설명회 참석 알림 메일 발송 완료: ${contactEmail}`)
+ } catch (emailError) {
+ console.error('메일 발송 실패:', emailError)
+ // 메일 발송 실패해도 참석 여부 업데이트는 성공으로 처리
+ }
+ } else {
+ console.warn('사양설명회 담당자 이메일이 없습니다.')
}
-
+
// 캐시 무효화
revalidateTag(`bidding-${biddingInfo[0].biddingId}`)
revalidateTag('quotation-vendors')
-
+
return {
...biddingInfo[0],
companyName,
diff --git a/lib/bidding/detail/table/bidding-detail-content.tsx b/lib/bidding/detail/table/bidding-detail-content.tsx
index 895016a2..05c7d567 100644
--- a/lib/bidding/detail/table/bidding-detail-content.tsx
+++ b/lib/bidding/detail/table/bidding-detail-content.tsx
@@ -9,8 +9,17 @@ import { BiddingDetailItemsDialog } from './bidding-detail-items-dialog'
import { BiddingDetailTargetPriceDialog } from './bidding-detail-target-price-dialog'
import { BiddingPreQuoteItemDetailsDialog } from '../../../bidding/pre-quote/table/bidding-pre-quote-item-details-dialog'
import { getPrItemsForBidding } from '../../../bidding/pre-quote/service'
+import { checkAllVendorsFinalSubmitted, performBidOpening } from '../bidding-actions'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
+import { useSession } from 'next-auth/react'
+import { BiddingNoticeEditor } from '@/lib/bidding/bidding-notice-editor'
+import { getBiddingNotice } from '@/lib/bidding/service'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { FileText, Eye, CheckCircle2, AlertCircle } from 'lucide-react'
interface BiddingDetailContentProps {
bidding: Bidding
@@ -27,12 +36,14 @@ export function BiddingDetailContent({
}: BiddingDetailContentProps) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
+ const session = useSession()
const [dialogStates, setDialogStates] = React.useState({
items: false,
targetPrice: false,
selectionReason: false,
- award: false
+ award: false,
+ biddingNotice: false
})
const [, setRefreshTrigger] = React.useState(0)
@@ -42,14 +53,119 @@ export function BiddingDetailContent({
const [selectedVendorForDetails, setSelectedVendorForDetails] = React.useState<QuotationVendor | null>(null)
const [prItemsForDialog, setPrItemsForDialog] = React.useState<any[]>([])
+ // 입찰공고 관련 state
+ const [biddingNotice, setBiddingNotice] = React.useState<any>(null)
+ const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false)
+
+ // 최종제출 현황 관련 state
+ const [finalSubmissionStatus, setFinalSubmissionStatus] = React.useState<{
+ allSubmitted: boolean
+ totalCompanies: number
+ submittedCompanies: number
+ }>({ allSubmitted: false, totalCompanies: 0, submittedCompanies: 0 })
+ const [isPerformingBidOpening, setIsPerformingBidOpening] = React.useState(false)
+
const handleRefresh = React.useCallback(() => {
setRefreshTrigger(prev => prev + 1)
}, [])
+ // 입찰공고 로드 함수
+ const loadBiddingNotice = React.useCallback(async () => {
+ if (!bidding.id) return
+
+ setIsBiddingNoticeLoading(true)
+ try {
+ const notice = await getBiddingNotice(bidding.id)
+ setBiddingNotice(notice)
+ } catch (error) {
+ console.error('Failed to load bidding notice:', error)
+ toast({
+ title: '오류',
+ description: '입찰공고문을 불러오는데 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsBiddingNoticeLoading(false)
+ }
+ }, [bidding.id, toast])
+
const openDialog = React.useCallback((type: keyof typeof dialogStates) => {
setDialogStates(prev => ({ ...prev, [type]: true }))
}, [])
+ // 최종제출 현황 로드 함수
+ const loadFinalSubmissionStatus = React.useCallback(async () => {
+ if (!bidding.id) return
+
+ try {
+ const status = await checkAllVendorsFinalSubmitted(bidding.id)
+ setFinalSubmissionStatus(status)
+ } catch (error) {
+ console.error('Failed to load final submission status:', error)
+ }
+ }, [bidding.id])
+
+ // 개찰 핸들러
+ const handlePerformBidOpening = async (isEarly: boolean = false) => {
+ if (!session.data?.user?.id) {
+ toast({
+ title: '권한 없음',
+ description: '로그인이 필요합니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (!finalSubmissionStatus.allSubmitted) {
+ toast({
+ title: '개찰 불가',
+ description: `모든 벤더가 최종 제출해야 개찰할 수 있습니다. (${finalSubmissionStatus.submittedCompanies}/${finalSubmissionStatus.totalCompanies})`,
+ variant: 'destructive',
+ })
+ return
+ }
+
+ const message = isEarly ? '조기개찰을 진행하시겠습니까?' : '개찰을 진행하시겠습니까?'
+ if (!window.confirm(message)) {
+ return
+ }
+
+ setIsPerformingBidOpening(true)
+ try {
+ const result = await performBidOpening(bidding.id, session.data.user.id.toString(), isEarly)
+
+ if (result.success) {
+ toast({
+ title: '개찰 완료',
+ description: result.message,
+ })
+ // 페이지 새로고침
+ window.location.reload()
+ } else {
+ toast({
+ title: '개찰 실패',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('Failed to perform bid opening:', error)
+ toast({
+ title: '오류',
+ description: '개찰에 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsPerformingBidOpening(false)
+ }
+ }
+
+ // 컴포넌트 마운트 시 입찰공고 및 최종제출 현황 로드
+ React.useEffect(() => {
+ loadBiddingNotice()
+ loadFinalSubmissionStatus()
+ }, [loadBiddingNotice, loadFinalSubmissionStatus])
+
const closeDialog = React.useCallback((type: keyof typeof dialogStates) => {
setDialogStates(prev => ({ ...prev, [type]: false }))
}, [])
@@ -73,8 +189,91 @@ export function BiddingDetailContent({
})
}, [bidding.id, toast])
+ // 개찰 버튼 표시 여부 (입찰평가중 상태에서만)
+ const showBidOpeningButtons = bidding.status === 'evaluation_of_bidding'
+
return (
<div className="space-y-6">
+ {/* 입찰공고 편집 버튼 */}
+ <div className="flex justify-between items-center">
+ <div>
+ <h2 className="text-2xl font-bold">입찰 상세</h2>
+ <p className="text-muted-foreground">{bidding.title}</p>
+ </div>
+ <Dialog open={dialogStates.biddingNotice} onOpenChange={(open) => setDialogStates(prev => ({ ...prev, biddingNotice: open }))}>
+ <DialogTrigger asChild>
+ <Button variant="outline" className="gap-2">
+ <FileText className="h-4 w-4" />
+ 입찰공고 편집
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>입찰공고 편집</DialogTitle>
+ </DialogHeader>
+ <div className="max-h-[60vh] overflow-y-auto">
+ <BiddingNoticeEditor
+ initialData={biddingNotice}
+ biddingId={bidding.id}
+ onSaveSuccess={() => setDialogStates(prev => ({ ...prev, biddingNotice: false }))}
+ />
+ </div>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ {/* 최종제출 현황 및 개찰 버튼 */}
+ {showBidOpeningButtons && (
+ <Card>
+ <CardContent className="pt-6">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4">
+ <div>
+ <div className="flex items-center gap-2 mb-1">
+ {finalSubmissionStatus.allSubmitted ? (
+ <CheckCircle2 className="h-5 w-5 text-green-600" />
+ ) : (
+ <AlertCircle className="h-5 w-5 text-yellow-600" />
+ )}
+ <h3 className="text-lg font-semibold">최종제출 현황</h3>
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-muted-foreground">
+ 최종 제출: {finalSubmissionStatus.submittedCompanies}/{finalSubmissionStatus.totalCompanies}개 업체
+ </span>
+ {finalSubmissionStatus.allSubmitted ? (
+ <Badge variant="default">모든 업체 제출 완료</Badge>
+ ) : (
+ <Badge variant="secondary">제출 대기 중</Badge>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 개찰 버튼들 */}
+ <div className="flex gap-2">
+ <Button
+ onClick={() => handlePerformBidOpening(false)}
+ disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening}
+ variant="default"
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ {isPerformingBidOpening ? '처리 중...' : '개찰'}
+ </Button>
+ <Button
+ onClick={() => handlePerformBidOpening(true)}
+ disabled={!finalSubmissionStatus.allSubmitted || isPerformingBidOpening}
+ variant="outline"
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ {isPerformingBidOpening ? '처리 중...' : '조기개찰'}
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
<BiddingDetailVendorTableContent
biddingId={bidding.id}
bidding={bidding}
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
index 1de7c768..10085e55 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-columns.tsx
@@ -130,17 +130,24 @@ export function getBiddingDetailVendorColumns({
},
},
{
- accessorKey: 'status',
+ accessorKey: 'invitationStatus',
header: '상태',
cell: ({ row }) => {
- const status = row.original.status
- const variant = status === 'selected' ? 'default' :
- status === 'submitted' ? 'secondary' :
- status === 'rejected' ? 'destructive' : 'outline'
+ const invitationStatus = row.original.invitationStatus
+ const variant = invitationStatus === 'bidding_submitted' ? 'default' :
+ invitationStatus === 'pre_quote_submitted' ? 'secondary' :
+ invitationStatus === 'bidding_declined' ? 'destructive' : 'outline'
- const label = status === 'selected' ? '선정' :
- status === 'submitted' ? '견적 제출' :
- status === 'rejected' ? '거절' : '대기'
+ const label = invitationStatus === 'bidding_submitted' ? '응찰 완료' :
+ invitationStatus === 'pre_quote_submitted' ? '사전견적 제출' :
+ invitationStatus === 'bidding_declined' ? '응찰 거절' :
+ invitationStatus === 'pre_quote_declined' ? '사전견적 거절' :
+ invitationStatus === 'bidding_accepted' ? '응찰 참여' :
+ invitationStatus === 'pre_quote_accepted' ? '사전견적 참여' :
+ invitationStatus === 'pending' ? '대기' :
+ invitationStatus === 'pre_quote_sent' ? '사전견적 초대' :
+ invitationStatus === 'bidding_sent' ? '응찰 초대' :
+ invitationStatus || '알 수 없음'
return <Badge variant={variant}>{label}</Badge>
},
diff --git a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
index e3b5c288..4d987739 100644
--- a/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
+++ b/lib/bidding/detail/table/bidding-detail-vendor-toolbar-actions.tsx
@@ -8,7 +8,7 @@ import { Plus, Send, RotateCcw, XCircle, Trophy, FileText, DollarSign } from "lu
import { registerBidding, markAsDisposal, createRebidding } from "@/lib/bidding/detail/service"
import { sendBiddingBasicContracts, getSelectedVendorsForBidding } from "@/lib/bidding/pre-quote/service"
-import { BiddingDetailVendorCreateDialog } from "./bidding-detail-vendor-create-dialog"
+import { BiddingDetailVendorCreateDialog } from "../../../../components/bidding/manage/bidding-detail-vendor-create-dialog"
import { BiddingDocumentUploadDialog } from "./bidding-document-upload-dialog"
import { BiddingVendorPricesDialog } from "./bidding-vendor-prices-dialog"
import { Bidding } from "@/db/schema"
@@ -189,13 +189,11 @@ export function BiddingDetailVendorToolbarActions({
variant="default"
size="sm"
onClick={handleRegister}
- disabled={isPending || bidding.status === 'received_quotation'}
+ disabled={isPending}
>
+ {/* 입찰등록 시점 재정의 필요*/}
<Send className="mr-2 h-4 w-4" />
입찰 등록
- {bidding.status === 'received_quotation' && (
- <span className="text-xs text-muted-foreground ml-2">(사전견적 제출 완료)</span>
- )}
</Button>
<Button
variant="destructive"
diff --git a/lib/bidding/detail/table/bidding-invitation-dialog.tsx b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
index cd79850a..ffb1fcb3 100644
--- a/lib/bidding/detail/table/bidding-invitation-dialog.tsx
+++ b/lib/bidding/detail/table/bidding-invitation-dialog.tsx
@@ -14,7 +14,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
-import { Separator } from '@/components/ui/separator'
import { Progress } from '@/components/ui/progress'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -22,24 +21,45 @@ import { cn } from '@/lib/utils'
import {
Mail,
Building2,
- Calendar,
FileText,
CheckCircle,
Info,
RefreshCw,
+ X,
+ ChevronDown,
Plus,
- X
+ UserPlus,
+ Users
} from 'lucide-react'
-import { sendBiddingBasicContracts, getSelectedVendorsForBidding, getExistingBasicContractsForBidding } from '../../pre-quote/service'
+import { getExistingBasicContractsForBidding } from '../../pre-quote/service'
import { getActiveContractTemplates } from '../../service'
+import { getVendorContacts } from '@/lib/vendors/service'
import { useToast } from '@/hooks/use-toast'
import { useTransition } from 'react'
+import { SelectTrigger } from '@/components/ui/select'
+import { SelectValue } from '@/components/ui/select'
+import { SelectContent } from '@/components/ui/select'
+import { SelectItem } from '@/components/ui/select'
+import { Select } from '@/components/ui/select'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Separator } from '@/components/ui/separator'
+
+
+interface VendorContact {
+ id: number
+ contactName: string
+ contactEmail: string
+ contactPhone?: string | null
+ contactPosition?: string | null
+ contactDepartment?: string | null
+}
interface VendorContractRequirement {
vendorId: number
vendorName: string
vendorCode?: string
vendorCountry?: string
+ vendorEmail?: string // 벤더의 기본 이메일 (vendors.email)
contactPerson?: string
contactEmail?: string
ndaYn?: boolean
@@ -50,6 +70,20 @@ interface VendorContractRequirement {
biddingId: number
}
+interface CustomEmail {
+ id: string
+ email: string
+ name?: string
+}
+
+interface VendorWithContactInfo extends VendorContractRequirement {
+ contacts: VendorContact[]
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails: CustomEmail[]
+ hasExistingContracts: boolean
+}
+
interface BasicContractTemplate {
id: number
templateName: string
@@ -74,25 +108,8 @@ interface BiddingInvitationDialogProps {
vendors: VendorContractRequirement[]
biddingId: number
biddingTitle: string
- projectName?: string
onSend: (data: {
- vendors: Array<{
- vendorId: number
- vendorName: string
- vendorCode?: string
- vendorCountry?: string
- selectedMainEmail: string
- additionalEmails: string[]
- contractRequirements: {
- ndaYn: boolean
- generalGtcYn: boolean
- projectGtcYn: boolean
- agreementYn: boolean
- }
- biddingCompanyId: number
- biddingId: number
- hasExistingContracts?: boolean
- }>
+ vendors: VendorWithContactInfo[]
generatedPdfs: Array<{
key: string
buffer: number[]
@@ -108,82 +125,206 @@ export function BiddingInvitationDialog({
vendors,
biddingId,
biddingTitle,
- projectName,
onSend,
}: BiddingInvitationDialogProps) {
const { toast } = useToast()
const [isPending, startTransition] = useTransition()
// 기본계약 관련 상태
- const [existingContracts, setExistingContracts] = React.useState<any[]>([])
+ const [, setExistingContractsList] = React.useState<Array<{ vendorId: number; biddingCompanyId: number }>>([])
const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false)
const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0)
const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('')
+ // 벤더 정보 상태 (담당자 선택 기능 포함)
+ const [vendorData, setVendorData] = React.useState<VendorWithContactInfo[]>([])
+
// 기본계약서 템플릿 관련 상태
- const [availableTemplates, setAvailableTemplates] = React.useState<any[]>([])
+ const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([])
const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([])
const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
const [additionalMessage, setAdditionalMessage] = React.useState('')
+ // 커스텀 이메일 관련 상태
+ const [showCustomEmailForm, setShowCustomEmailForm] = React.useState<Record<number, boolean>>({})
+ const [customEmailInputs, setCustomEmailInputs] = React.useState<Record<number, { email: string; name: string }>>({})
+ const [customEmailCounter, setCustomEmailCounter] = React.useState(0)
+
+ // 벤더 정보 업데이트 함수
+ const updateVendor = React.useCallback((vendorId: number, updates: Partial<VendorWithContactInfo>) => {
+ setVendorData(prev => prev.map(vendor =>
+ vendor.vendorId === vendorId ? { ...vendor, ...updates } : vendor
+ ))
+ }, [])
+
+ // CC 이메일 토글
+ const toggleAdditionalEmail = React.useCallback((vendorId: number, email: string) => {
+ setVendorData(prev => prev.map(vendor => {
+ if (vendor.vendorId === vendorId) {
+ const additionalEmails = vendor.additionalEmails.includes(email)
+ ? vendor.additionalEmails.filter(e => e !== email)
+ : [...vendor.additionalEmails, email]
+ return { ...vendor, additionalEmails }
+ }
+ return vendor
+ }))
+ }, [])
+
+ // 커스텀 이메일 추가
+ const addCustomEmail = React.useCallback((vendorId: number) => {
+ const input = customEmailInputs[vendorId]
+ if (!input?.email) return
+
+ setVendorData(prev => prev.map(vendor => {
+ if (vendor.vendorId === vendorId) {
+ const newCustomEmail: CustomEmail = {
+ id: `custom-${customEmailCounter}`,
+ email: input.email,
+ name: input.name || input.email
+ }
+ return {
+ ...vendor,
+ customEmails: [...vendor.customEmails, newCustomEmail]
+ }
+ }
+ return vendor
+ }))
+
+ setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendorId]: { email: '', name: '' }
+ }))
+ setCustomEmailCounter(prev => prev + 1)
+ }, [customEmailInputs, customEmailCounter])
+
+ // 커스텀 이메일 제거
+ const removeCustomEmail = React.useCallback((vendorId: number, customEmailId: string) => {
+ setVendorData(prev => prev.map(vendor => {
+ if (vendor.vendorId === vendorId) {
+ return {
+ ...vendor,
+ customEmails: vendor.customEmails.filter(ce => ce.id !== customEmailId),
+ additionalEmails: vendor.additionalEmails.filter(email =>
+ !vendor.customEmails.find(ce => ce.id === customEmailId)?.email || email !== vendor.customEmails.find(ce => ce.id === customEmailId)?.email
+ )
+ }
+ }
+ return vendor
+ }))
+ }, [])
+
+ // 총 수신자 수 계산
+ const totalRecipientCount = React.useMemo(() => {
+ return vendorData.reduce((sum, vendor) => {
+ return sum + 1 + vendor.additionalEmails.length // 주 수신자 1명 + CC
+ }, 0)
+ }, [vendorData])
+
// 선택된 업체들 (사전견적에서 선정된 업체들만)
const selectedVendors = React.useMemo(() =>
vendors.filter(vendor => vendor.ndaYn || vendor.generalGtcYn || vendor.projectGtcYn || vendor.agreementYn),
[vendors]
)
- // 기존 계약이 있는 업체들과 없는 업체들 분리
+ // 기존 계약이 있는 업체들 분리
const vendorsWithExistingContracts = React.useMemo(() =>
- selectedVendors.filter(vendor =>
- existingContracts.some((ec: any) =>
- ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
- )
- ),
- [selectedVendors, existingContracts]
+ vendorData.filter(vendor => vendor.hasExistingContracts),
+ [vendorData]
)
- const vendorsWithoutExistingContracts = React.useMemo(() =>
- selectedVendors.filter(vendor =>
- !existingContracts.some((ec: any) =>
- ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
- )
- ),
- [selectedVendors, existingContracts]
- )
-
- // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드
+ // 다이얼로그가 열릴 때 기존 계약 조회, 템플릿 로드, 벤더 담당자 로드
React.useEffect(() => {
- if (open) {
+ if (open && selectedVendors.length > 0) {
const fetchInitialData = async () => {
setIsLoadingTemplates(true);
try {
- const [contractsResult, templatesData] = await Promise.all([
- getSelectedVendorsForBidding(biddingId),
+ const [existingContractsResult, templatesData] = await Promise.all([
+ getExistingBasicContractsForBidding(biddingId),
getActiveContractTemplates(),
]);
- // 기존 계약 조회 (사전견적에서 보낸 기본계약 확인) - 서버 액션 사용
- const existingContracts = await getExistingBasicContractsForBidding(biddingId);
- setExistingContracts(existingContracts.success ? existingContracts.contracts || [] : []);
+ // 기존 계약 조회
+ const contracts = existingContractsResult.success ? existingContractsResult.contracts || [] : [];
+ const typedContracts = contracts.map(c => ({
+ vendorId: c.vendorId || 0,
+ biddingCompanyId: c.biddingCompanyId || 0
+ }));
+ setExistingContractsList(typedContracts);
// 템플릿 로드 (4개 타입만 필터링)
- // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료
const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료'];
const rawTemplates = templatesData.templates || [];
- const filteredTemplates = rawTemplates.filter((template: any) =>
+ const filteredTemplates = rawTemplates.filter((template: BasicContractTemplate) =>
allowedTemplateNames.some(allowedName =>
template.templateName.includes(allowedName) ||
allowedName.includes(template.templateName)
)
);
- setAvailableTemplates(filteredTemplates as any);
- const initialSelected = filteredTemplates.map((template: any) => ({
+ setAvailableTemplates(filteredTemplates);
+ const initialSelected = filteredTemplates.map((template: BasicContractTemplate) => ({
templateId: template.id,
templateName: template.templateName,
contractType: template.templateName,
checked: false
}));
setSelectedContracts(initialSelected);
+
+ // 벤더 담당자 정보 병렬로 가져오기
+ const vendorContactsPromises = selectedVendors.map(vendor =>
+ getVendorContacts({
+ page: 1,
+ perPage: 100,
+ flags: [],
+ sort: [],
+ filters: [],
+ joinOperator: 'and',
+ search: '',
+ contactName: '',
+ contactPosition: '',
+ contactEmail: '',
+ contactPhone: ''
+ }, vendor.vendorId)
+ .then(result => ({
+ vendorId: vendor.vendorId,
+ contacts: (result.data || []).map(contact => ({
+ id: contact.id,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone,
+ contactPosition: contact.contactPosition,
+ contactDepartment: contact.contactDepartment
+ }))
+ }))
+ .catch(() => ({
+ vendorId: vendor.vendorId,
+ contacts: []
+ }))
+ );
+
+ const vendorContactsResults = await Promise.all(vendorContactsPromises);
+ const vendorContactsMap = new Map(vendorContactsResults.map(result => [result.vendorId, result.contacts]));
+
+ // vendorData 초기화 (담당자 정보 포함)
+ const initialVendorData: VendorWithContactInfo[] = selectedVendors.map(vendor => {
+ const hasExistingContract = typedContracts.some((ec) =>
+ ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
+ );
+ const vendorContacts = vendorContactsMap.get(vendor.vendorId) || [];
+
+ // 주 수신자 기본값: 벤더의 기본 이메일 (vendorEmail)
+ const defaultEmail = vendor.vendorEmail || (vendorContacts.length > 0 ? vendorContacts[0].contactEmail : '');
+ console.log(defaultEmail, "defaultEmail");
+ return {
+ ...vendor,
+ contacts: vendorContacts,
+ selectedMainEmail: defaultEmail,
+ additionalEmails: [],
+ customEmails: [],
+ hasExistingContracts: hasExistingContract
+ };
+ });
+
+ setVendorData(initialVendorData);
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
toast({
@@ -193,13 +334,14 @@ export function BiddingInvitationDialog({
});
setAvailableTemplates([]);
setSelectedContracts([]);
+ setVendorData([]);
} finally {
setIsLoadingTemplates(false);
}
}
fetchInitialData();
}
- }, [open, biddingId, toast]);
+ }, [open, biddingId, selectedVendors, toast]);
const handleOpenChange = (open: boolean) => {
onOpenChange(open)
@@ -209,6 +351,7 @@ export function BiddingInvitationDialog({
setIsGeneratingPdfs(false)
setPdfGenerationProgress(0)
setCurrentGeneratingContract('')
+ setVendorData([])
}
}
@@ -245,32 +388,32 @@ export function BiddingInvitationDialog({
vendorId,
}),
});
-
+
if (!prepareResponse.ok) {
throw new Error("템플릿 준비 실패");
}
-
+
const { template: preparedTemplate, templateData } = await prepareResponse.json();
-
+
// 2. 템플릿 파일 다운로드
const templateResponse = await fetch("/api/contracts/get-template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ templatePath: preparedTemplate.filePath }),
});
-
+
const templateBlob = await templateResponse.blob();
const templateFile = new window.File([templateBlob], "template.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
-
+
// 3. PDFTron WebViewer로 PDF 변환
const { default: WebViewer } = await import("@pdftron/webviewer");
-
+
const tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
-
+
try {
const instance = await WebViewer(
{
@@ -280,29 +423,29 @@ export function BiddingInvitationDialog({
},
tempDiv
);
-
+
const { Core } = instance;
const { createDocument } = Core;
-
+
const templateDoc = await createDocument(templateFile, {
filename: templateFile.name,
extension: 'docx',
});
-
+
// 변수 치환 적용
await templateDoc.applyTemplateValues(templateData);
-
+
// PDF 변환
const fileData = await templateDoc.getFileData();
const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
-
+
const fileName = `${template.templateName}_${Date.now()}.pdf`;
-
+
return {
buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
fileName
};
-
+
} finally {
if (tempDiv.parentNode) {
document.body.removeChild(tempDiv);
@@ -333,43 +476,39 @@ export function BiddingInvitationDialog({
setPdfGenerationProgress(0)
let generatedCount = 0;
- for (const vendor of selectedVendors) {
- // 사전견적에서 이미 기본계약을 보낸 벤더인지 확인
- const hasExistingContract = existingContracts.some((ec: any) =>
- ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
- );
-
- if (hasExistingContract) {
- console.log(`벤더 ${vendor.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`);
+ for (const vendorWithContact of vendorData) {
+ // 기존 계약이 있는 경우 건너뛰기
+ if (vendorWithContact.hasExistingContracts) {
+ console.log(`벤더 ${vendorWithContact.vendorName}는 사전견적에서 이미 기본계약을 받았으므로 건너뜁니다.`);
generatedCount++;
- setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100);
+ setPdfGenerationProgress((generatedCount / vendorData.length) * 100);
continue;
}
- for (const contract of selectedContractTemplates) {
- setCurrentGeneratingContract(`${vendor.vendorName} - ${contract.templateName}`);
- const templateDetails = availableTemplates.find(t => t.id === contract.templateId);
-
- if (templateDetails) {
- const pdfData = await generateBasicContractPdf(templateDetails, vendor.vendorId);
- // sendBiddingBasicContracts와 동일한 키 형식 사용
- let contractType = '';
- if (contract.templateName.includes('비밀')) {
- contractType = 'NDA';
- } else if (contract.templateName.includes('General GTC')) {
- contractType = 'General_GTC';
- } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) {
- contractType = 'Project_GTC';
- } else if (contract.templateName.includes('기술자료')) {
- contractType = '기술자료';
+ for (const contract of selectedContractTemplates) {
+ setCurrentGeneratingContract(`${vendorWithContact.vendorName} - ${contract.templateName}`);
+ const templateDetails = availableTemplates.find(t => t.id === contract.templateId);
+
+ if (templateDetails) {
+ const pdfData = await generateBasicContractPdf(templateDetails, vendorWithContact.vendorId);
+ // sendBiddingBasicContracts와 동일한 키 형식 사용
+ let contractType = '';
+ if (contract.templateName.includes('비밀')) {
+ contractType = 'NDA';
+ } else if (contract.templateName.includes('General GTC')) {
+ contractType = 'General_GTC';
+ } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) {
+ contractType = 'Project_GTC';
+ } else if (contract.templateName.includes('기술자료')) {
+ contractType = '기술자료';
+ }
+ const key = `${vendorWithContact.vendorId}_${contractType}_${contract.templateName}`;
+ generatedPdfsMap.set(key, pdfData);
}
- const key = `${vendor.vendorId}_${contractType}_${contract.templateName}`;
- generatedPdfsMap.set(key, pdfData);
}
+ generatedCount++;
+ setPdfGenerationProgress((generatedCount / vendorData.length) * 100);
}
- generatedCount++;
- setPdfGenerationProgress((generatedCount / selectedVendors.length) * 100);
- }
setIsGeneratingPdfs(false);
@@ -382,30 +521,6 @@ export function BiddingInvitationDialog({
generatedPdfs = pdfsArray;
}
- const vendorData = selectedVendors.map(vendor => {
- const hasExistingContract = existingContracts.some((ec: any) =>
- ec.vendorId === vendor.vendorId && ec.biddingCompanyId === vendor.biddingCompanyId
- );
-
- return {
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- vendorCode: vendor.vendorCode,
- vendorCountry: vendor.vendorCountry,
- selectedMainEmail: vendor.contactEmail || '',
- additionalEmails: [],
- contractRequirements: {
- ndaYn: vendor.ndaYn || false,
- generalGtcYn: vendor.generalGtcYn || false,
- projectGtcYn: vendor.projectGtcYn || false,
- agreementYn: vendor.agreementYn || false
- },
- biddingCompanyId: vendor.biddingCompanyId,
- biddingId: vendor.biddingId,
- hasExistingContracts: hasExistingContract
- };
- });
-
await onSend({
vendors: vendorData,
generatedPdfs: generatedPdfs,
@@ -428,7 +543,7 @@ export function BiddingInvitationDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col" style={{width:900, maxWidth:900}}>
+ <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col" style={{ width: 900, maxWidth: 900 }}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
@@ -453,72 +568,299 @@ export function BiddingInvitationDialog({
</Alert>
)}
- {/* 대상 업체 정보 */}
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="flex items-center gap-2 text-base">
- <Building2 className="h-5 w-5 text-green-600" />
- 초대 대상 업체 ({selectedVendors.length}개)
- </CardTitle>
- </CardHeader>
- <CardContent>
- {selectedVendors.length === 0 ? (
- <div className="text-center py-6 text-muted-foreground">
- 초대 가능한 업체가 없습니다.
- </div>
- ) : (
- <div className="space-y-4">
- {/* 계약서가 생성될 업체들 */}
- {vendorsWithoutExistingContracts.length > 0 && (
- <div>
- <h4 className="text-sm font-medium text-green-700 mb-2 flex items-center gap-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- 계약서 생성 대상 ({vendorsWithoutExistingContracts.length}개)
- </h4>
- <div className="space-y-2 max-h-32 overflow-y-auto">
- {vendorsWithoutExistingContracts.map((vendor) => (
- <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-green-50 rounded border border-green-200">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium">{vendor.vendorName}</span>
- <Badge variant="outline" className="text-xs">
- {vendor.vendorCode}
- </Badge>
- </div>
- ))}
- </div>
- </div>
- )}
+ {/* 대상 업체 정보 - 테이블 형식 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <Building2 className="h-4 w-4" />
+ 초대 대상 업체 ({vendorData.length})
+ </div>
+ <Badge variant="outline" className="flex items-center gap-1">
+ <Users className="h-3 w-3" />
+ 총 {totalRecipientCount}명
+ </Badge>
+ </div>
- {/* 기존 계약이 있는 업체들 */}
- {vendorsWithExistingContracts.length > 0 && (
- <div>
- <h4 className="text-sm font-medium text-orange-700 mb-2 flex items-center gap-2">
- <X className="h-4 w-4 text-orange-600" />
- 기존 계약 존재 (계약서 재생성 건너뜀) ({vendorsWithExistingContracts.length}개)
- </h4>
- <div className="space-y-2 max-h-32 overflow-y-auto">
- {vendorsWithExistingContracts.map((vendor) => (
- <div key={vendor.vendorId} className="flex items-center gap-2 text-sm p-2 bg-orange-50 rounded border border-orange-200">
- <X className="h-4 w-4 text-orange-600" />
- <span className="font-medium">{vendor.vendorName}</span>
- <Badge variant="outline" className="text-xs">
- {vendor.vendorCode}
- </Badge>
- <Badge variant="secondary" className="text-xs bg-orange-100 text-orange-800">
- 계약 존재 (재생성 건너뜀)
- </Badge>
- <Badge variant="outline" className="text-xs border-green-500 text-green-700">
- 본입찰 초대
- </Badge>
- </div>
- ))}
- </div>
- </div>
- )}
- </div>
- )}
- </CardContent>
- </Card>
+ {vendorData.length === 0 ? (
+ <div className="text-center py-6 text-muted-foreground border rounded-lg">
+ 초대 가능한 업체가 없습니다.
+ </div>
+ ) : (
+ <div className="border rounded-lg overflow-hidden">
+ <table className="w-full">
+ <thead className="bg-muted/50 border-b">
+ <tr>
+ <th className="text-left p-2 text-xs font-medium">No.</th>
+ <th className="text-left p-2 text-xs font-medium">업체명</th>
+ <th className="text-left p-2 text-xs font-medium">주 수신자</th>
+ <th className="text-left p-2 text-xs font-medium">CC</th>
+ <th className="text-left p-2 text-xs font-medium">작업</th>
+ </tr>
+ </thead>
+ <tbody>
+ {vendorData.map((vendor, index) => {
+ const allContacts = vendor.contacts || [];
+ const allEmails = [
+ // 벤더의 기본 이메일을 첫 번째로 표시
+ ...(vendor.vendorEmail ? [{
+ value: vendor.vendorEmail,
+ label: `${vendor.vendorEmail}`,
+ email: vendor.vendorEmail,
+ type: 'vendor' as const
+ }] : []),
+ // 담당자 이메일들
+ ...allContacts.map(c => ({
+ value: c.contactEmail,
+ label: `${c.contactName} ${c.contactPosition ? `(${c.contactPosition})` : ''}`,
+ email: c.contactEmail,
+ type: 'contact' as const
+ })),
+ // 커스텀 이메일들
+ ...vendor.customEmails.map(c => ({
+ value: c.email,
+ label: c.name || c.email,
+ email: c.email,
+ type: 'custom' as const
+ }))
+ ];
+
+ const ccEmails = allEmails.filter(e => e.value !== vendor.selectedMainEmail);
+ const selectedMainEmailInfo = allEmails.find(e => e.value === vendor.selectedMainEmail);
+ const isFormOpen = showCustomEmailForm[vendor.vendorId];
+
+ return (
+ <React.Fragment key={vendor.vendorId}>
+ <tr className="border-b hover:bg-muted/20">
+ <td className="p-2">
+ <div className="flex items-center gap-1">
+ <div className="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-medium">
+ {index + 1}
+ </div>
+ </div>
+ </td>
+ <td className="p-2">
+ <div className="space-y-1">
+ <div className="font-medium text-sm">{vendor.vendorName}</div>
+ <div className="flex items-center gap-1">
+ <Badge variant="outline" className="text-xs">
+ {vendor.vendorCountry || vendor.vendorCode}
+ </Badge>
+ </div>
+ </div>
+ </td>
+ <td className="p-2">
+ <Select
+ value={vendor.selectedMainEmail}
+ onValueChange={(value) => updateVendor(vendor.vendorId, { selectedMainEmail: value })}
+ >
+ <SelectTrigger className="h-7 text-xs w-[200px]">
+ <SelectValue placeholder="선택하세요">
+ {selectedMainEmailInfo && (
+ <div className="flex items-center gap-1">
+ {selectedMainEmailInfo.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />}
+ <span className="truncate">{selectedMainEmailInfo.label}</span>
+ </div>
+ )}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {allEmails.map((email) => (
+ <SelectItem key={email.value} value={email.value} className="text-xs">
+ <div className="flex items-center gap-1">
+ {email.type === 'custom' && <UserPlus className="h-3 w-3 text-green-500" />}
+ <span>{email.label}</span>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {!vendor.selectedMainEmail && (
+ <span className="text-xs text-red-500">필수</span>
+ )}
+ </td>
+ <td className="p-2">
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="outline" className="h-7 text-xs">
+ {vendor.additionalEmails.length > 0
+ ? `${vendor.additionalEmails.length}명`
+ : "선택"
+ }
+ <ChevronDown className="ml-1 h-3 w-3" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-48 p-2">
+ <div className="max-h-48 overflow-y-auto space-y-1">
+ {ccEmails.map((email) => (
+ <div key={email.value} className="flex items-center space-x-1 p-1">
+ <Checkbox
+ checked={vendor.additionalEmails.includes(email.value)}
+ onCheckedChange={() => toggleAdditionalEmail(vendor.vendorId, email.value)}
+ className="h-3 w-3"
+ />
+ <label className="text-xs cursor-pointer flex-1 truncate">
+ {email.label}
+ </label>
+ </div>
+ ))}
+ </div>
+ </PopoverContent>
+ </Popover>
+ </td>
+ <td className="p-2">
+ <div className="flex items-center gap-1">
+ <Button
+ variant={isFormOpen ? "default" : "ghost"}
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => {
+ setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendor.vendorId]: !prev[vendor.vendorId]
+ }));
+ }}
+ >
+ {isFormOpen ? <X className="h-3 w-3" /> : <Plus className="h-3 w-3" />}
+ </Button>
+ {vendor.customEmails.length > 0 && (
+ <Badge variant="secondary" className="text-xs">
+ +{vendor.customEmails.length}
+ </Badge>
+ )}
+ </div>
+ </td>
+ </tr>
+
+ {/* 인라인 수신자 추가 폼 */}
+ {isFormOpen && (
+ <tr className="bg-muted/10 border-b">
+ <td colSpan={5} className="p-4">
+ <div className="space-y-3">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-2 text-sm font-medium">
+ <UserPlus className="h-4 w-4" />
+ 수신자 추가 - {vendor.vendorName}
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0"
+ onClick={() => setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendor.vendorId]: false
+ }))}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+
+ <div className="flex gap-2 items-end">
+ <div className="w-[150px]">
+ <Label className="text-xs mb-1 block">이름 (선택)</Label>
+ <Input
+ placeholder="홍길동"
+ className="h-8 text-sm"
+ value={customEmailInputs[vendor.vendorId]?.name || ''}
+ onChange={(e) => setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendor.vendorId]: {
+ ...prev[vendor.vendorId],
+ name: e.target.value
+ }
+ }))}
+ />
+ </div>
+ <div className="flex-1">
+ <Label className="text-xs mb-1 block">이메일 <span className="text-red-500">*</span></Label>
+ <Input
+ type="email"
+ placeholder="example@company.com"
+ className="h-8 text-sm"
+ value={customEmailInputs[vendor.vendorId]?.email || ''}
+ onChange={(e) => setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendor.vendorId]: {
+ ...prev[vendor.vendorId],
+ email: e.target.value
+ }
+ }))}
+ onKeyPress={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ addCustomEmail(vendor.vendorId);
+ }
+ }}
+ />
+ </div>
+ <Button
+ size="sm"
+ className="h-8 px-4"
+ onClick={() => addCustomEmail(vendor.vendorId)}
+ disabled={!customEmailInputs[vendor.vendorId]?.email}
+ >
+ <Plus className="h-3 w-3 mr-1" />
+ 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 px-4"
+ onClick={() => {
+ setCustomEmailInputs(prev => ({
+ ...prev,
+ [vendor.vendorId]: { email: '', name: '' }
+ }));
+ setShowCustomEmailForm(prev => ({
+ ...prev,
+ [vendor.vendorId]: false
+ }));
+ }}
+ >
+ 취소
+ </Button>
+ </div>
+
+ {/* 추가된 커스텀 이메일 목록 */}
+ {vendor.customEmails.length > 0 && (
+ <div className="mt-3 pt-3 border-t">
+ <div className="text-xs text-muted-foreground mb-2">추가된 수신자 목록</div>
+ <div className="grid grid-cols-2 xl:grid-cols-3 gap-2">
+ {vendor.customEmails.map((custom) => (
+ <div key={custom.id} className="flex items-center justify-between bg-background rounded-md p-2">
+ <div className="flex items-center gap-2 min-w-0">
+ <UserPlus className="h-3 w-3 text-green-500 flex-shrink-0" />
+ <div className="min-w-0">
+ <div className="text-sm font-medium truncate">{custom.name}</div>
+ <div className="text-xs text-muted-foreground truncate">{custom.email}</div>
+ </div>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 flex-shrink-0"
+ onClick={() => removeCustomEmail(vendor.vendorId, custom.id)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </td>
+ </tr>
+ )}
+ </React.Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+
+ <Separator />
{/* 기본계약서 선택 */}
<Card>
@@ -685,4 +1027,4 @@ export function BiddingInvitationDialog({
</DialogContent>
</Dialog>
)
-}
+} \ No newline at end of file
diff --git a/lib/bidding/failure/biddings-failure-columns.tsx b/lib/bidding/failure/biddings-failure-columns.tsx
new file mode 100644
index 00000000..8a888079
--- /dev/null
+++ b/lib/bidding/failure/biddings-failure-columns.tsx
@@ -0,0 +1,320 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Eye, Calendar, FileX, DollarSign, AlertTriangle, RefreshCw
+} 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 {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+type BiddingFailureItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+
+ // 가격 정보
+ targetPrice: number | null
+ currency: string | null
+
+ // 일정 정보
+ biddingRegistrationDate: Date | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+
+ // 담당자 정보
+ bidPicName: string | null
+ supplyPicName: string | null
+
+ // 유찰 정보
+ disposalDate: Date | null // 유찰일
+ disposalUpdatedAt: Date | null // 폐찰수정일
+ disposalUpdatedBy: string | null // 폐찰수정자
+
+ // 기타 정보
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+ updatedBy: string | null
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingFailureItem> | null>>
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ 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 getBiddingsFailureColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingFailureItem>[] {
+
+ return [
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ </div>
+ ),
+ size: 120,
+ 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 font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ 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: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 내정가 ░░░
+ {
+ accessorKey: "targetPrice",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />,
+ cell: ({ row }) => {
+ const price = row.original.targetPrice
+ const currency = row.original.currency || 'KRW'
+
+ return (
+ <div className="text-sm font-medium text-orange-600">
+ {price ? formatCurrency(price, currency) : '-'}
+ </div>
+ )
+ },
+ size: 120,
+ meta: { excelHeader: "내정가" },
+ },
+
+ // ░░░ 통화 ░░░
+ {
+ accessorKey: "currency",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.currency || 'KRW'}</span>
+ ),
+ size: 60,
+ 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: "bidPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return <span className="text-sm">{displayName}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ 유찰일 ░░░
+ {
+ id: "disposalDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="유찰일" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <AlertTriangle className="h-4 w-4 text-red-500" />
+ <span className="text-sm">{formatDate(row.original.disposalDate, "KR")}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "유찰일" },
+ },
+
+ // ░░░ 폐찰일 ░░░
+ {
+ id: "disposalUpdatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰일" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <FileX className="h-4 w-4 text-red-500" />
+ <span className="text-sm">{formatDate(row.original.disposalUpdatedAt, "KR")}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "폐찰일" },
+ },
+
+ // ░░░ 폐찰수정자 ░░░
+ {
+ id: "disposalUpdatedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="폐찰수정자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.disposalUpdatedBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "폐찰수정자" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 등록자 ░░░
+ {
+ accessorKey: "createdBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
+ },
+
+ // ░░░ 등록일시 ░░░
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.createdAt, "KR")}</span>
+ ),
+ size: 100,
+ 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>
+ <FileX 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: "history" })}>
+ <Calendar className="mr-2 h-4 w-4" />
+ 이력보기
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "rebid" })}>
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 재입찰
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+}
diff --git a/lib/bidding/failure/biddings-failure-table.tsx b/lib/bidding/failure/biddings-failure-table.tsx
new file mode 100644
index 00000000..901648d2
--- /dev/null
+++ b/lib/bidding/failure/biddings-failure-table.tsx
@@ -0,0 +1,223 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+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 { getBiddingsFailureColumns } from "./biddings-failure-columns"
+import { getBiddingsForFailure } from "@/lib/bidding/service"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+
+type BiddingFailureItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+
+ // 가격 정보
+ targetPrice: number | null
+ currency: string | null
+
+ // 일정 정보
+ biddingRegistrationDate: Date | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+
+ // 담당자 정보
+ bidPicName: string | null
+ supplyPicName: string | null
+
+ // 유찰 정보
+ disposalDate: Date | null // 유찰일
+ disposalUpdatedAt: Date | null // 폐찰수정일
+ disposalUpdatedBy: string | null // 폐찰수정자
+
+ // 기타 정보
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+ updatedBy: string | null
+}
+
+interface BiddingsFailureTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBiddingsForFailure>>
+ ]
+ >
+}
+
+export function BiddingsFailureTable({ promises }: BiddingsFailureTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingFailureItem | null>(null)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingFailureItem> | null>(null)
+
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ const columns = React.useMemo(
+ () => getBiddingsFailureColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // rowAction 변경 감지하여 해당 다이얼로그 열기
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedBidding(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ break
+ case "history":
+ // 이력보기 (추후 구현)
+ console.log('이력보기:', rowAction.row.original)
+ break
+ case "rebid":
+ // 재입찰 (추후 구현)
+ console.log('재입찰:', rowAction.row.original)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction])
+
+ const filterFields: DataTableFilterField<BiddingFailureItem>[] = [
+ {
+ id: "biddingNumber",
+ label: "입찰번호",
+ type: "text",
+ placeholder: "입찰번호를 입력하세요",
+ },
+ {
+ id: "prNumber",
+ label: "P/R번호",
+ type: "text",
+ placeholder: "P/R번호를 입력하세요",
+ },
+ {
+ id: "title",
+ label: "입찰명",
+ type: "text",
+ placeholder: "입찰명을 입력하세요",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<BiddingFailureItem>[] = [
+ { id: "title", label: "입찰명", type: "text" },
+ { id: "biddingNumber", label: "입찰번호", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
+ {
+ id: "status",
+ label: "진행상태",
+ type: "multi-select",
+ options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "contractType",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "biddingRegistrationDate", label: "입찰등록일", type: "date" },
+ { id: "disposalDate", label: "유찰일", type: "date" },
+ { id: "disposalUpdatedAt", label: "폐찰일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "disposalDate", desc: true }], // 유찰일 기준 최신순
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+ const handleSpecMeetingDialogClose = React.useCallback(() => {
+ setSpecMeetingDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
+ const handlePrDocumentsDialogClose = React.useCallback(() => {
+ setPrDocumentsDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsFailureTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 사양설명회 다이얼로그 */}
+ <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ />
+
+ {/* PR 문서 다이얼로그 */}
+ <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/list/bidding-detail-dialogs.tsx b/lib/bidding/list/bidding-detail-dialogs.tsx
index 4fbca616..065000ce 100644
--- a/lib/bidding/list/bidding-detail-dialogs.tsx
+++ b/lib/bidding/list/bidding-detail-dialogs.tsx
@@ -9,58 +9,49 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Separator } from "@/components/ui/separator"
import { ScrollArea } from "@/components/ui/scroll-area"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { Input } from "@/components/ui/input"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
-import {
- CalendarIcon,
- ClockIcon,
+import {
+ CalendarIcon,
+ ClockIcon,
MapPinIcon,
FileTextIcon,
- DownloadIcon,
- EyeIcon,
- PackageIcon,
- HashIcon,
- DollarSignIcon,
- WeightIcon,
- ExternalLinkIcon
+ ExternalLinkIcon,
+ FileXIcon,
+ UploadIcon
} from "lucide-react"
-import { toast } from "sonner"
import { BiddingListItem } from "@/db/schema"
import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download"
-import { getPRDetailsAction, getSpecificationMeetingDetailsAction } from "../service"
+import { getSpecificationMeetingDetailsAction } from "../service"
+import { bidClosureAction } from "../actions"
import { formatDate } from "@/lib/utils"
+import { toast } from "sonner"
// 타입 정의
interface SpecificationMeetingDetails {
id: number;
biddingId: number;
meetingDate: string;
- meetingTime: string | null;
- location: string | null;
- address: string | null;
- contactPerson: string | null;
- contactPhone: string | null;
- contactEmail: string | null;
- agenda: string | null;
- materials: string | null;
- notes: string | null;
+ meetingTime?: string | null;
+ location?: string | null;
+ address?: string | null;
+ contactPerson?: string | null;
+ contactPhone?: string | null;
+ contactEmail?: string | null;
+ agenda?: string | null;
+ materials?: string | null;
+ notes?: string | null;
isRequired: boolean;
createdAt: string;
updatedAt: string;
@@ -70,60 +61,13 @@ interface SpecificationMeetingDetails {
originalFileName: string;
fileSize: number;
filePath: string;
- title: string | null;
+ title?: string | null;
uploadedAt: string;
- uploadedBy: string | null;
- }>;
-}
-
-interface PRDetails {
- documents: Array<{
- id: number;
- documentName: string;
- fileName: string;
- originalFileName: string;
- fileSize: number;
- filePath: string;
- registeredAt: string;
- registeredBy: string | null;
- version: string | null;
- description: string | null;
- createdAt: string;
- updatedAt: string;
- }>;
- items: Array<{
- id: number;
- itemNumber: string;
- itemInfo: string | null;
- quantity: number | null;
- quantityUnit: string | null;
- requestedDeliveryDate: string | null;
- prNumber: string | null;
- annualUnitPrice: number | null;
- currency: string | null;
- totalWeight: number | null;
- weightUnit: string | null;
- materialDescription: string | null;
- hasSpecDocument: boolean;
- createdAt: string;
- updatedAt: string;
- specDocuments: Array<{
- id: number;
- fileName: string;
- originalFileName: string;
- fileSize: number;
- filePath: string;
- uploadedAt: string;
- title: string | null;
- }>;
+ uploadedBy?: string | null;
}>;
}
-interface ActionResult<T> {
- success: boolean;
- data?: T;
- error?: string;
-}
+// PR 관련 타입과 컴포넌트는 bidding-pr-documents-dialog.tsx로 이동됨
// 파일 다운로드 훅
const useFileDownload = () => {
@@ -212,52 +156,6 @@ const FileDownloadLink: React.FC<FileDownloadLinkProps> = ({
);
};
-// 파일 다운로드 버튼 컴포넌트 (간소화된 버전)
-interface FileDownloadButtonProps {
- filePath: string;
- fileName: string;
- fileSize?: number;
- title?: string | null;
- variant?: "download" | "preview";
- size?: "sm" | "default" | "lg";
-}
-
-const FileDownloadButton: React.FC<FileDownloadButtonProps> = ({
- filePath,
- fileName,
- fileSize,
- title,
- variant = "download",
- size = "sm"
-}) => {
- const { handleDownload, downloadingFiles } = useFileDownload();
- const fileInfo = getFileInfo(fileName);
- const fileKey = `${filePath}_${fileName}`;
- const isDownloading = downloadingFiles.has(fileKey);
-
- const Icon = variant === "preview" && fileInfo.canPreview ? EyeIcon : DownloadIcon;
-
- return (
- <Button
- onClick={() => handleDownload(filePath, fileName, { action: variant })}
- disabled={isDownloading}
- size={size}
- variant="outline"
- className="gap-2"
- >
- <Icon className="h-4 w-4" />
- {isDownloading ? "처리중..." : (
- variant === "preview" && fileInfo.canPreview ? "미리보기" : "다운로드"
- )}
- {fileSize && size !== "sm" && (
- <span className="text-xs text-muted-foreground">
- ({formatFileSize(fileSize)})
- </span>
- )}
- </Button>
- );
-};
-
// 사양설명회 다이얼로그
interface SpecificationMeetingDialogProps {
open: boolean;
@@ -458,285 +356,131 @@ export function SpecificationMeetingDialog({
);
}
-// PR 문서 다이얼로그
-interface PrDocumentsDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- bidding: BiddingListItem | null;
-}
-
-export function PrDocumentsDialog({
- open,
- onOpenChange,
- bidding
-}: PrDocumentsDialogProps) {
- const [data, setData] = useState<PRDetails | null>(null);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState<string | null>(null);
-
- useEffect(() => {
- if (open && bidding) {
- fetchPRData();
- }
- }, [open, bidding]);
-
- const fetchPRData = async () => {
- if (!bidding) return;
-
- setLoading(true);
- setError(null);
-
- try {
- const result = await getPRDetailsAction(bidding.id);
-
- if (result.success && result.data) {
- setData(result.data);
- } else {
- setError(result.error || "PR 문서 정보를 불러올 수 없습니다.");
- }
- } catch (err) {
- setError("데이터 로딩 중 오류가 발생했습니다.");
- console.error("Failed to fetch PR data:", err);
- } finally {
- setLoading(false);
- }
- };
-
- const formatCurrency = (amount: number | null, currency: string | null) => {
- if (amount === null) return "-";
- return `${amount.toLocaleString()} ${currency || ""}`;
- };
-
- const formatWeight = (weight: number | null, unit: string | null) => {
- if (weight === null) return "-";
- return `${weight.toLocaleString()} ${unit || ""}`;
- };
+// PR 문서 다이얼로그는 bidding-pr-documents-dialog.tsx로 이동됨
+// import { PrDocumentsDialog } from './bidding-pr-documents-dialog'로 사용하세요
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-7xl max-h-[90vh]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <PackageIcon className="h-5 w-5" />
- PR 문서
- </DialogTitle>
- <DialogDescription>
- {bidding?.title}의 PR 문서 및 아이템 정보입니다.
- </DialogDescription>
- </DialogHeader>
-
- <ScrollArea className="max-h-[75vh]">
- {loading ? (
- <div className="flex items-center justify-center py-8">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
- <p className="text-sm text-muted-foreground">로딩 중...</p>
- </div>
- </div>
- ) : error ? (
- <div className="flex items-center justify-center py-8">
- <div className="text-center">
- <p className="text-sm text-destructive mb-2">{error}</p>
- <Button onClick={fetchPRData} size="sm">
- 다시 시도
- </Button>
- </div>
- </div>
- ) : data ? (
- <div className="space-y-6">
- {/* PR 문서 목록 */}
- {data.documents.length > 0 && (
- <Card>
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <FileTextIcon className="h-5 w-5" />
- PR 문서 ({data.documents.length}개)
- </CardTitle>
- </CardHeader>
- <CardContent>
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>문서명</TableHead>
- <TableHead>파일명</TableHead>
- <TableHead>버전</TableHead>
- <TableHead>크기</TableHead>
- <TableHead>등록일</TableHead>
- <TableHead>등록자</TableHead>
- <TableHead className="text-right">다운로드</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {data.documents.map((doc) => (
- <TableRow key={doc.id}>
- <TableCell className="font-medium">
- {doc.documentName}
- {doc.description && (
- <div className="text-xs text-muted-foreground mt-1">
- {doc.description}
- </div>
- )}
- </TableCell>
- <TableCell>
- <FileDownloadLink
- filePath={doc.filePath}
- fileName={doc.originalFileName}
- fileSize={doc.fileSize}
- />
- </TableCell>
- <TableCell>
- {doc.version ? (
- <Badge variant="outline">{doc.version}</Badge>
- ) : "-"}
- </TableCell>
- <TableCell>{formatFileSize(doc.fileSize)}</TableCell>
- <TableCell>
- {new Date(doc.registeredAt).toLocaleDateString('ko-KR')}
- </TableCell>
- <TableCell>{doc.registeredBy || "-"}</TableCell>
- <TableCell className="text-right">
- <FileDownloadButton
- filePath={doc.filePath}
- fileName={doc.originalFileName}
- fileSize={doc.fileSize}
- variant="download"
- size="sm"
- />
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </CardContent>
- </Card>
- )}
+// 폐찰하기 다이얼로그
+interface BidClosureDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bidding: BiddingListItem | null;
+ userId: string;
+}
- {/* PR 아이템 테이블 */}
- {data.items.length > 0 && (
- <Card>
- <CardHeader>
- <CardTitle className="text-lg flex items-center gap-2">
- <HashIcon className="h-5 w-5" />
- PR 아이템 ({data.items.length}개)
- </CardTitle>
- </CardHeader>
- <CardContent>
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead className="w-[100px]">아이템 번호</TableHead>
- <TableHead className="w-[150px]">PR 번호</TableHead>
- <TableHead>아이템 정보</TableHead>
- <TableHead className="w-[120px]">수량</TableHead>
- <TableHead className="w-[120px]">단가</TableHead>
- <TableHead className="w-[120px]">중량</TableHead>
- <TableHead className="w-[120px]">요청 납기</TableHead>
- <TableHead className="w-[200px]">스펙 문서</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {data.items.map((item) => (
- <TableRow key={item.id}>
- <TableCell className="font-medium">
- {item.itemNumber}
- </TableCell>
- <TableCell>
- {item.prNumber || "-"}
- </TableCell>
- <TableCell>
- <div>
- {item.itemInfo && (
- <div className="font-medium text-sm mb-1">{item.itemInfo}</div>
- )}
- {item.materialDescription && (
- <div className="text-xs text-muted-foreground">
- {item.materialDescription}
- </div>
- )}
- </div>
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-1">
- <PackageIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {item.quantity ? `${item.quantity.toLocaleString()} ${item.quantityUnit || ""}` : "-"}
- </span>
- </div>
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-1">
- <DollarSignIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {formatCurrency(item.annualUnitPrice, item.currency)}
- </span>
- </div>
- </TableCell>
- <TableCell>
- <div className="flex items-center gap-1">
- <WeightIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {formatWeight(item.totalWeight, item.weightUnit)}
- </span>
- </div>
- </TableCell>
- <TableCell>
- {item.requestedDeliveryDate ? (
- <div className="flex items-center gap-1">
- <CalendarIcon className="h-3 w-3 text-muted-foreground" />
- <span className="text-sm">
- {new Date(item.requestedDeliveryDate).toLocaleDateString('ko-KR')}
- </span>
- </div>
- ) : "-"}
- </TableCell>
- <TableCell>
- <div className="space-y-1">
- <div className="flex items-center gap-2">
- <Badge variant={item.hasSpecDocument ? "default" : "secondary"} className="text-xs">
- {item.hasSpecDocument ? "있음" : "없음"}
- </Badge>
- {item.specDocuments.length > 0 && (
- <span className="text-xs text-muted-foreground">
- ({item.specDocuments.length}개)
- </span>
- )}
- </div>
- {item.specDocuments.length > 0 && (
- <div className="space-y-1">
- {item.specDocuments.map((doc, index) => (
- <div key={doc.id} className="text-xs">
- <FileDownloadLink
- filePath={doc.filePath}
- fileName={doc.originalFileName}
- fileSize={doc.fileSize}
- title={doc.title}
- className="text-xs"
- />
- </div>
- ))}
- </div>
- )}
- </div>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </CardContent>
- </Card>
- )}
+export function BidClosureDialog({
+ open,
+ onOpenChange,
+ bidding,
+ userId
+}: BidClosureDialogProps) {
+ const [description, setDescription] = useState('')
+ const [files, setFiles] = useState<File[]>([])
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+
+ if (!bidding || !description.trim()) {
+ toast.error('폐찰 사유를 입력해주세요.')
+ return
+ }
+
+ setIsSubmitting(true)
+
+ try {
+ const result = await bidClosureAction(bidding.id, {
+ description: description.trim(),
+ files
+ }, userId)
+
+ if (result.success) {
+ toast.success(result.message)
+ onOpenChange(false)
+ // 페이지 새로고침 또는 상태 업데이트
+ window.location.reload()
+ } else {
+ toast.error(result.error || '폐찰 처리 중 오류가 발생했습니다.')
+ }
+ } catch (error) {
+ toast.error('폐찰 처리 중 오류가 발생했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files) {
+ setFiles(Array.from(e.target.files))
+ }
+ }
+
+ if (!bidding) return null
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileXIcon className="h-5 w-5 text-destructive" />
+ 폐찰하기
+ </DialogTitle>
+ <DialogDescription>
+ {bidding.title} ({bidding.biddingNumber})를 폐찰합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <form onSubmit={handleSubmit} className="space-y-4">
+ <div className="space-y-2">
+ <Label htmlFor="description">폐찰 사유 <span className="text-destructive">*</span></Label>
+ <Textarea
+ id="description"
+ placeholder="폐찰 사유를 입력해주세요..."
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="min-h-[100px]"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="files">첨부파일</Label>
+ <Input
+ id="files"
+ type="file"
+ multiple
+ onChange={handleFileChange}
+ className="cursor-pointer"
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.jpg,.jpeg,.png"
+ />
+ {files.length > 0 && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: {files.map(f => f.name).join(', ')}
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end gap-2 pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ variant="destructive"
+ disabled={isSubmitting || !description.trim()}
+ >
+ {isSubmitting ? '처리 중...' : '폐찰하기'}
+ </Button>
+ </div>
+ </form>
+ </DialogContent>
+ </Dialog>
+ )
+}
- {/* 데이터가 없는 경우 */}
- {data.documents.length === 0 && data.items.length === 0 && (
- <div className="text-center py-8">
- <FileTextIcon className="h-12 w-12 text-muted-foreground mx-auto mb-2" />
- <p className="text-muted-foreground">PR 문서가 없습니다.</p>
- </div>
- )}
- </div>
- ) : null}
- </ScrollArea>
- </DialogContent>
- </Dialog>
- );
-} \ No newline at end of file
+// Re-export for backward compatibility
+export { PrDocumentsDialog } from './bidding-pr-documents-dialog' \ No newline at end of file
diff --git a/lib/bidding/list/bidding-pr-documents-dialog.tsx b/lib/bidding/list/bidding-pr-documents-dialog.tsx
new file mode 100644
index 00000000..ad377ee5
--- /dev/null
+++ b/lib/bidding/list/bidding-pr-documents-dialog.tsx
@@ -0,0 +1,405 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useCallback } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import {
+ CalendarIcon,
+ FileTextIcon,
+ PackageIcon,
+ HashIcon,
+ DollarSignIcon,
+} from "lucide-react"
+import { BiddingListItem } from "@/db/schema"
+import { formatFileSize } from "@/lib/file-download"
+import { getPRDetailsAction, type PRDetails } from "../service"
+
+// 파일 다운로드 컴포넌트
+const FileDownloadLink = ({
+ filePath,
+ fileName,
+ fileSize,
+ title,
+ className = ""
+}: {
+ filePath: string;
+ fileName: string;
+ fileSize: number;
+ title?: string | null;
+ className?: string;
+}) => {
+ return (
+ <a
+ href={filePath}
+ download={fileName}
+ className={`text-blue-600 hover:underline ${className}`}
+ title={title || fileName}
+ >
+ {title || fileName} <span className="text-xs text-gray-500">({formatFileSize(fileSize)})</span>
+ </a>
+ );
+};
+
+const FileDownloadButton = ({
+ filePath,
+ fileName,
+ variant = "download",
+ size = "sm"
+}: {
+ filePath: string;
+ fileName: string;
+ variant?: "download" | "preview";
+ size?: "sm" | "default";
+}) => {
+ return (
+ <Button
+ variant="outline"
+ size={size}
+ asChild
+ >
+ <a href={filePath} download={fileName}>
+ {variant === "download" ? "다운로드" : "미리보기"}
+ </a>
+ </Button>
+ );
+};
+
+// PR 문서 다이얼로그
+interface PrDocumentsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bidding: BiddingListItem | null;
+}
+
+export function PrDocumentsDialog({
+ open,
+ onOpenChange,
+ bidding
+}: PrDocumentsDialogProps) {
+ const [data, setData] = useState<PRDetails | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchPRData = useCallback(async () => {
+ if (!bidding) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const result = await getPRDetailsAction(bidding.id);
+
+ if (result.success && result.data) {
+ setData(result.data as PRDetails);
+ } else {
+ setError(result.error || "PR 문서 정보를 불러올 수 없습니다.");
+ }
+ } catch (err) {
+ setError("데이터 로딩 중 오류가 발생했습니다.");
+ console.error("Failed to fetch PR data:", err);
+ } finally {
+ setLoading(false);
+ }
+ }, [bidding]);
+
+ useEffect(() => {
+ if (open && bidding) {
+ fetchPRData();
+ }
+ }, [open, bidding, fetchPRData]);
+
+ const formatCurrency = (amount: number | null, currency: string | null) => {
+ if (amount == null) return "-";
+ return `${amount.toLocaleString()} ${currency || ""}`;
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-7xl max-h-[90vh]" style={{ maxWidth: "80vw" }}>
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <PackageIcon className="h-5 w-5" />
+ PR 및 문서 정보
+ </DialogTitle>
+ <DialogDescription>
+ {bidding?.title}의 PR 문서 및 아이템 정보입니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <ScrollArea className="max-h-[75vh]">
+ {loading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
+ <p className="text-sm text-muted-foreground">로딩 중...</p>
+ </div>
+ </div>
+ ) : error ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <p className="text-sm text-destructive mb-2">{error}</p>
+ <Button onClick={fetchPRData} size="sm">
+ 다시 시도
+ </Button>
+ </div>
+ </div>
+ ) : data ? (
+ <div className="space-y-6">
+ {/* PR 문서 목록 */}
+ {data.documents.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <FileTextIcon className="h-5 w-5" />
+ PR 문서 ({data.documents.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>문서명</TableHead>
+ <TableHead>파일명</TableHead>
+ <TableHead>버전</TableHead>
+ <TableHead>크기</TableHead>
+ <TableHead>등록일</TableHead>
+ <TableHead>등록자</TableHead>
+ <TableHead className="text-right">다운로드</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.documents.map((doc) => (
+ <TableRow key={doc.id}>
+ <TableCell className="font-medium">
+ {doc.documentName}
+ {doc.description && (
+ <div className="text-xs text-muted-foreground mt-1">
+ {doc.description}
+ </div>
+ )}
+ </TableCell>
+ <TableCell>
+ <FileDownloadLink
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ fileSize={doc.fileSize}
+ />
+ </TableCell>
+ <TableCell>
+ {doc.version ? (
+ <Badge variant="outline">{doc.version}</Badge>
+ ) : "-"}
+ </TableCell>
+ <TableCell>{formatFileSize(doc.fileSize)}</TableCell>
+ <TableCell>
+ {new Date(doc.registeredAt).toLocaleDateString('ko-KR')}
+ </TableCell>
+ <TableCell>{doc.registeredBy || "-"}</TableCell>
+ <TableCell className="text-right">
+ <FileDownloadButton
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ variant="download"
+ size="sm"
+ />
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* PR 아이템 테이블 */}
+ {data.items.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg flex items-center gap-2">
+ <HashIcon className="h-5 w-5" />
+ PR 아이템 ({data.items.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="overflow-x-auto">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[80px]">아이템 번호</TableHead>
+ <TableHead className="w-[120px]">PR 번호</TableHead>
+ <TableHead className="w-[150px]">자재그룹</TableHead>
+ <TableHead className="w-[150px]">자재</TableHead>
+ <TableHead className="w-[200px]">품목정보</TableHead>
+ <TableHead className="w-[100px]">수량</TableHead>
+ <TableHead className="w-[80px]">구매단위</TableHead>
+ <TableHead className="w-[100px]">내정단가</TableHead>
+ <TableHead className="w-[100px]">내정금액</TableHead>
+ <TableHead className="w-[100px]">예산금액</TableHead>
+ <TableHead className="w-[100px]">실적금액</TableHead>
+ <TableHead className="w-[100px]">WBS코드</TableHead>
+ <TableHead className="w-[100px]">요청 납기</TableHead>
+ <TableHead className="w-[150px]">스펙 문서</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {data.items.map((item) => (
+ <TableRow key={item.id}>
+ <TableCell className="font-medium font-mono text-xs">
+ {item.itemNumber || "-"}
+ </TableCell>
+ <TableCell className="font-mono text-xs">
+ {item.prNumber || "-"}
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.materialGroupNumber && (
+ <div className="font-mono text-xs">{item.materialGroupNumber}</div>
+ )}
+ {item.materialGroupInfo && (
+ <div className="text-xs text-muted-foreground">{item.materialGroupInfo}</div>
+ )}
+ {!item.materialGroupNumber && !item.materialGroupInfo && "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.materialNumber && (
+ <div className="font-mono text-xs">{item.materialNumber}</div>
+ )}
+ {item.materialInfo && (
+ <div className="text-xs text-muted-foreground">{item.materialInfo}</div>
+ )}
+ {!item.materialNumber && !item.materialInfo && "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-xs">
+ {item.itemInfo || "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <PackageIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {item.quantity ? `${item.quantity.toLocaleString()} ${item.quantityUnit || ""}` : "-"}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell className="text-xs">
+ {item.purchaseUnit || "-"}
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <DollarSignIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {formatCurrency(item.targetUnitPrice, item.targetCurrency)}
+ </span>
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm font-medium">
+ {formatCurrency(item.targetAmount, item.targetCurrency)}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm">
+ {formatCurrency(item.budgetAmount, item.budgetCurrency)}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm">
+ {formatCurrency(item.actualAmount, item.actualCurrency)}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div>
+ {item.wbsCode && (
+ <div className="font-mono text-xs">{item.wbsCode}</div>
+ )}
+ {item.wbsName && (
+ <div className="text-xs text-muted-foreground">{item.wbsName}</div>
+ )}
+ {!item.wbsCode && !item.wbsName && "-"}
+ </div>
+ </TableCell>
+ <TableCell>
+ {item.requestedDeliveryDate ? (
+ <div className="flex items-center gap-1">
+ <CalendarIcon className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {new Date(item.requestedDeliveryDate).toLocaleDateString('ko-KR')}
+ </span>
+ </div>
+ ) : "-"}
+ </TableCell>
+ <TableCell>
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <Badge variant={item.hasSpecDocument ? "default" : "secondary"} className="text-xs">
+ {item.hasSpecDocument ? "있음" : "없음"}
+ </Badge>
+ {item.specDocuments.length > 0 && (
+ <span className="text-xs text-muted-foreground">
+ ({item.specDocuments.length}개)
+ </span>
+ )}
+ </div>
+ {item.specDocuments.length > 0 && (
+ <div className="space-y-1">
+ {item.specDocuments.map((doc) => (
+ <div key={doc.id} className="text-xs">
+ <FileDownloadLink
+ filePath={doc.filePath}
+ fileName={doc.originalFileName}
+ fileSize={doc.fileSize}
+ title={doc.title}
+ className="text-xs"
+ />
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 데이터가 없는 경우 */}
+ {data.documents.length === 0 && data.items.length === 0 && (
+ <div className="text-center py-8">
+ <FileTextIcon className="h-12 w-12 text-muted-foreground mx-auto mb-2" />
+ <p className="text-muted-foreground">PR 문서가 없습니다.</p>
+ </div>
+ )}
+ </div>
+ ) : null}
+ </ScrollArea>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/lib/bidding/list/biddings-table-columns.tsx b/lib/bidding/list/biddings-table-columns.tsx
index d6044e93..10966e0e 100644
--- a/lib/bidding/list/biddings-table-columns.tsx
+++ b/lib/bidding/list/biddings-table-columns.tsx
@@ -5,18 +5,10 @@ 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 { getUserCodeByEmail } from "@/lib/bidding/service"
import {
- Eye, Edit, MoreHorizontal, FileText, Users, Calendar,
- Building, Package, DollarSign, Clock, CheckCircle, XCircle,
- AlertTriangle
+ Eye, Edit, MoreHorizontal, FileX
} from "lucide-react"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
+
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,14 +22,13 @@ import { DataTableRowAction } from "@/types/table"
// BiddingListItem에 manager 정보 추가
type BiddingListItemWithManagerCode = BiddingListItem & {
- managerName?: string | null
- managerCode?: string | null
+ bidPicName?: string | null
+ supplyPicName?: string | null
}
import {
biddingStatusLabels,
contractTypeLabels,
biddingTypeLabels,
- awardCountLabels
} from "@/db/schema"
import { formatDate } from "@/lib/utils"
@@ -68,23 +59,6 @@ const getStatusBadgeVariant = (status: string) => {
}
}
-// 금액 포맷팅
-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<BiddingListItemWithManagerCode>[] {
return [
@@ -132,442 +106,256 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
meta: { excelHeader: "입찰 No." },
},
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+ // ░░░ 프로젝트명 ░░░
+ {
+ 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: "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 font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
// ░░░ 입찰상태 ░░░
{
accessorKey: "status",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰상태" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="진행상태" />,
cell: ({ row }) => (
<Badge variant={getStatusBadgeVariant(row.original.status)}>
{biddingStatusLabels[row.original.status]}
</Badge>
),
size: 120,
- meta: { excelHeader: "입찰상태" },
+ meta: { excelHeader: "진행상태" },
},
-
- // ░░░ 긴급여부 ░░░
+ // ░░░ 입찰유형 ░░░
{
- accessorKey: "isUrgent",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="긴급여부" />,
- cell: ({ row }) => {
- const isUrgent = row.original.isUrgent
+ accessorKey: "biddingType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰유형" />,
+ cell: ({ row }) => (
+ <Badge variant="secondary">
+ {biddingTypeLabels[row.original.biddingType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "입찰유형" },
+ },
- return isUrgent ? (
- <div className="flex items-center gap-1">
- <AlertTriangle className="h-4 w-4 text-red-600" />
- <Badge variant="destructive" className="text-xs">
- 긴급
- </Badge>
- </div>
- ) : (
- <div className="flex items-center gap-1">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="text-xs text-muted-foreground">일반</span>
- </div>
- )
- },
- size: 90,
- meta: { excelHeader: "긴급여부" },
+ // ░░░ 통화 ░░░
+ {
+ accessorKey: "currency",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.currency}</span>
+ ),
+ size: 60,
+ 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: "budget",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="예산" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium">
+ {row.original.budget}
+ </span>
+ ),
+ size: 120,
+ meta: { excelHeader: "예산" },
},
+ // ░░░ 내정가 ░░░
+ {
+ accessorKey: "targetPrice",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium text-orange-600">
+ {row.original.targetPrice}
+ </span>
+ ),
+ size: 120,
+ meta: { excelHeader: "내정가" },
+ },
// ░░░ 입찰담당자 ░░░
{
- accessorKey: "managerName",
+ accessorKey: "bidPicName",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
cell: ({ row }) => {
- const name = row.original.managerName || "-";
- const managerCode = row.original.managerCode || "";
- return name === "-" ? "-" : `${name}(${managerCode})`;
+ const name = row.original.bidPicName || "-";
+ return name;
},
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 font-bold underline"
- onClick={() => setRowAction({ row, type: "view" })}
- >
- <div className="whitespace-pre-line">
- {row.original.title}
- </div>
- </Button>
- </div>
- ),
- size: 200,
- 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: "입찰등록일" },
},
- // ═══════════════════════════════════════════════════════════════
- // 계약 정보
- // ═══════════════════════════════════════════════════════════════
{
- 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: "낙찰수" },
- },
-
- {
- id: "contractPeriod",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />,
- cell: ({ row }) => {
- const startDate = row.original.contractStartDate
- const endDate = row.original.contractEndDate
-
- if (!startDate || !endDate) {
- return <span className="text-muted-foreground">-</span>
- }
-
- return (
- <div className="text-xs max-w-[120px] truncate" title={`${formatDate(startDate, "KR")} ~ ${formatDate(endDate, "KR")}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
- </div>
- )
- },
- size: 120,
- meta: { excelHeader: "계약기간" },
- },
- ]
+ 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: "입찰서제출기간" },
},
-
- // ═══════════════════════════════════════════════════════════════
- // 일정 정보
- // ═══════════════════════════════════════════════════════════════
+ // ░░░ 사양설명회 ░░░
{
- 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: "사양설명회" },
- },
- ]
+ 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">
- {row.original.budget}
- </span>
- ),
- size: 120,
- meta: { excelHeader: "예산" },
- },
-
- {
- accessorKey: "targetPrice",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내정가" />,
- cell: ({ row }) => (
- <span className="text-sm font-medium text-orange-600">
- {row.original.targetPrice}
- </span>
- ),
- size: 120,
- meta: { excelHeader: "내정가" },
- },
-
- {
- accessorKey: "finalBidPrice",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종입찰가" />,
- cell: ({ row }) => (
- <span className="text-sm font-medium text-green-600">
- {row.original.finalBidPrice}
- </span>
- ),
- size: 120,
- meta: { excelHeader: "최종입찰가" },
- },
- ]
+ {
+ accessorKey: "updatedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.updatedBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
},
-
- // ═══════════════════════════════════════════════════════════════
- // 참여 현황
- // ═══════════════════════════════════════════════════════════════
+ // 등록일시
{
- header: "참여 현황",
- columns: [
- {
- id: "participantExpected",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정" />,
- cell: ({ row }) => (
- <Badge variant="outline" className="font-mono">
- {row.original.participantExpected}
- </Badge>
- ),
- size: 80,
- meta: { excelHeader: "참여예정" },
- },
-
- {
- id: "participantParticipated",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여" />,
- cell: ({ row }) => (
- <Badge variant="default" className="font-mono">
- {row.original.participantParticipated}
- </Badge>
- ),
- size: 60,
- meta: { excelHeader: "참여" },
- },
-
- {
- id: "participantDeclined",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기" />,
- cell: ({ row }) => (
- <Badge variant="destructive" className="font-mono">
- {row.original.participantDeclined}
- </Badge>
- ),
- size: 60,
- 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: "등록일시" },
},
-
// ═══════════════════════════════════════════════════════════════
// 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
+ // {
+ // 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: "최종수정자" },
- },
- ]
- },
+ // 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 문서" },
+ // },
+ // ]
+ // },
// ░░░ 비고 ░░░
{
@@ -611,6 +399,17 @@ export function getBiddingsColumns({ setRowAction }: GetColumnsProps): ColumnDef
<span className="text-xs text-muted-foreground ml-2">(수정 불가)</span>
)}
</DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: "bid_closure" })}
+ disabled={row.original.status !== 'bidding_disposal'}
+ >
+ <FileX className="mr-2 h-4 w-4" />
+ 폐찰하기
+ {row.original.status !== 'bidding_disposal' && (
+ <span className="text-xs text-muted-foreground ml-2">(유찰 시에만 가능)</span>
+ )}
+ </DropdownMenuItem>
{/* <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setRowAction({ row, type: "copy" })}>
<Package className="mr-2 h-4 w-4" />
diff --git a/lib/bidding/list/biddings-table-toolbar-actions.tsx b/lib/bidding/list/biddings-table-toolbar-actions.tsx
index 702396ae..0cb87b11 100644
--- a/lib/bidding/list/biddings-table-toolbar-actions.tsx
+++ b/lib/bidding/list/biddings-table-toolbar-actions.tsx
@@ -3,10 +3,9 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
import {
- Plus, Send, Download, FileSpreadsheet
+ Send, Download, FileSpreadsheet
} from "lucide-react"
import { toast } from "sonner"
-import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
@@ -14,32 +13,74 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { BiddingListItem } from "@/db/schema"
-import { CreateBiddingDialog } from "./create-bidding-dialog"
+// import { CreateBiddingDialog } from "./create-bidding-dialog"
import { TransmissionDialog } from "./biddings-transmission-dialog"
+import { BiddingCreateDialog } from "@/components/bidding/create/bidding-create-dialog"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { createBiddingSchema } from "@/lib/bidding/validation"
interface BiddingsTableToolbarActionsProps {
table: Table<BiddingListItem>
}
export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActionsProps) {
- const router = useRouter()
const { data: session } = useSession()
const [isExporting, setIsExporting] = React.useState(false)
const [isTransmissionDialogOpen, setIsTransmissionDialogOpen] = React.useState(false)
const userId = session?.user?.id ? Number(session.user.id) : 1
+ // 입찰 생성 폼
+ const form = useForm({
+ resolver: zodResolver(createBiddingSchema),
+ defaultValues: {
+ revision: 0,
+ title: '',
+ description: '',
+ content: '',
+ noticeType: 'standard' as const,
+ contractType: 'general' as const,
+ biddingType: 'equipment' as const,
+ awardCount: 'single' as const,
+ currency: 'KRW',
+ status: 'bidding_generated' as const,
+ bidPicName: '',
+ bidPicCode: '',
+ supplyPicName: '',
+ supplyPicCode: '',
+ requesterName: '',
+ attachments: [],
+ vendorAttachments: [],
+ hasSpecificationMeeting: false,
+ hasPrDocument: false,
+ isPublic: false,
+ isUrgent: false,
+ purchasingOrganization: '',
+ biddingConditions: {
+ paymentTerms: '',
+ taxConditions: 'V1',
+ incoterms: 'DAP',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ },
+ },
+ })
+
// 선택된 입찰들
const selectedBiddings = React.useMemo(() => {
return table
.getFilteredSelectedRowModel()
.rows
.map(row => row.original)
- }, [table.getFilteredSelectedRowModel().rows])
+ }, [table])
// 업체선정이 완료된 입찰만 전송 가능
const canTransmit = selectedBiddings.length === 1 && selectedBiddings[0].status === 'vendor_selected'
@@ -52,19 +93,22 @@ export function BiddingsTableToolbarActions({ table }: BiddingsTableToolbarActio
excludeColumns: ["select", "actions"],
})
toast.success("입찰 목록이 성공적으로 내보내졌습니다.")
- } catch (error) {
+ } catch {
toast.error("내보내기 중 오류가 발생했습니다.")
} finally {
setIsExporting(false)
}
}
+
return (
<>
<div className="flex items-center gap-2">
- {/* 신규 생성 */}
- <CreateBiddingDialog
- />
+ {/* 신규입찰 생성 버튼 */}
+ <BiddingCreateDialog form={form} onSuccess={() => {
+ // 성공 시 테이블 새로고침 등 추가 작업
+ // window.location.reload()
+ }} />
{/* 전송하기 (업체선정 완료된 입찰만) */}
<Button
diff --git a/lib/bidding/list/biddings-table.tsx b/lib/bidding/list/biddings-table.tsx
index 8920d9db..39952d5a 100644
--- a/lib/bidding/list/biddings-table.tsx
+++ b/lib/bidding/list/biddings-table.tsx
@@ -2,6 +2,7 @@
import * as React from "react"
import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
import type {
DataTableAdvancedFilterField,
DataTableFilterField,
@@ -22,7 +23,7 @@ import {
biddingTypeLabels
} from "@/db/schema"
import { EditBiddingSheet } from "./edit-bidding-sheet"
-import { SpecificationMeetingDialog, PrDocumentsDialog } from "./bidding-detail-dialogs"
+import { SpecificationMeetingDialog, PrDocumentsDialog, BidClosureDialog } from "./bidding-detail-dialogs"
interface BiddingsTableProps {
@@ -43,6 +44,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
const [isCompact, setIsCompact] = React.useState<boolean>(false)
const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [bidClosureDialogOpen, setBidClosureDialogOpen] = React.useState(false)
const [selectedBidding, setSelectedBidding] = React.useState<BiddingListItemWithManagerCode | null>(null)
console.log(data,"data")
@@ -50,6 +52,7 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingListItemWithManagerCode> | null>(null)
const router = useRouter()
+ const { data: session } = useSession()
const columns = React.useMemo(
() => getBiddingsColumns({ setRowAction }),
@@ -63,8 +66,8 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
switch (rowAction.type) {
case "view":
- // 상세 페이지로 이동
- router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ // 상세 페이지로 이동 (info 페이지로)
+ router.push(`/evcp/bid/${rowAction.row.original.id}/info`)
break
case "update":
// EditBiddingSheet는 아래에서 별도로 처리
@@ -75,6 +78,9 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
case "pr_documents":
setPrDocumentsDialogOpen(true)
break
+ case "bid_closure":
+ setBidClosureDialogOpen(true)
+ break
// 기존 다른 액션들은 그대로 유지
default:
break
@@ -88,10 +94,10 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
{ id: "title", label: "입찰명", type: "text" },
{ id: "biddingNumber", label: "입찰번호", type: "text" },
{ id: "projectName", label: "프로젝트명", type: "text" },
- { id: "managerName", label: "담당자", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
{
id: "status",
- label: "입찰상태",
+ label: "진행상태",
type: "multi-select",
options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
label,
@@ -154,6 +160,12 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
setSelectedBidding(null)
}, [])
+ const handleBidClosureDialogClose = React.useCallback(() => {
+ setBidClosureDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
return (
<>
@@ -195,6 +207,14 @@ export function BiddingsTable({ promises }: BiddingsTableProps) {
onOpenChange={handlePrDocumentsDialogClose}
bidding={selectedBidding}
/>
+
+ {/* 폐찰하기 다이얼로그 */}
+ <BidClosureDialog
+ open={bidClosureDialogOpen}
+ onOpenChange={handleBidClosureDialogClose}
+ bidding={selectedBidding}
+ userId={session?.user?.id ? String(session.user.id) : ''}
+ />
</>
)
diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx
index 50246f58..20ea740f 100644
--- a/lib/bidding/list/create-bidding-dialog.tsx
+++ b/lib/bidding/list/create-bidding-dialog.tsx
@@ -1,2242 +1,2114 @@
-"use client"
+'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 * as React from 'react'
+import { useRouter } from 'next/navigation'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table"
+ Loader2,
+ Plus,
+ Trash2,
+ FileText,
+ Paperclip,
+ ChevronRight,
+ ChevronLeft,
+ X,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+
+import { Button } from '@/components/ui/button'
import {
- Dropzone,
- DropzoneDescription,
- DropzoneInput,
- DropzoneTitle,
- DropzoneUploadIcon,
- DropzoneZone,
-} from "@/components/ui/dropzone"
+ Dialog,
+ DialogContent,
+ DialogTrigger,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
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 { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from "@/lib/procurement-select/service"
-import { TAX_CONDITIONS } from "@/lib/tax-conditions/types"
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
import {
- createBiddingSchema,
- type CreateBiddingSchema
-} from "@/lib/bidding/validation"
+ 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 { Label } from '@/components/ui/label'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Tabs, TabsContent } from '@/components/ui/tabs'
+import { Checkbox } from '@/components/ui/checkbox'
+
+import { createBidding } from '@/lib/bidding/service'
import {
- biddingStatusLabels,
- contractTypeLabels,
- biddingTypeLabels,
- awardCountLabels
-} from "@/db/schema"
-import { ProjectSelector } from "@/components/ProjectSelector"
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog"
+ getIncotermsForSelection,
+ getPaymentTermsForSelection,
+ getPlaceOfShippingForSelection,
+ getPlaceOfDestinationForSelection,
+} from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS } from '@/lib/tax-conditions/types'
+import { createBiddingSchema, type CreateBiddingSchema } from '@/lib/bidding/validation'
+import { contractTypeLabels, biddingTypeLabels, awardCountLabels } from '@/db/schema'
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
+import { cn } from '@/lib/utils'
+import { MaterialGroupSingleSelector } from '@/components/common/material/material-group-single-selector'
+import { MaterialSingleSelector } from '@/components/common/selectors/material/material-single-selector'
+import { PurchaseGroupCodeSelector } from '@/components/common/selectors/purchase-group-code/purchase-group-code-selector'
+import { ProcurementManagerSelector } from '@/components/common/selectors/procurement-manager/procurement-manager-selector'
+import { WbsCodeSingleSelector } from '@/components/common/selectors/wbs-code/wbs-code-single-selector'
+import { CostCenterSingleSelector } from '@/components/common/selectors/cost-center/cost-center-single-selector'
+import { GlAccountSingleSelector } from '@/components/common/selectors/gl-account/gl-account-single-selector'
+import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
+import { MaterialSelectorDialogSingle } from '@/components/common/selectors/material/material-selector-dialog-single'
+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[] // 사양설명회 첨부파일
+ 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
- totalWeight: string
- weightUnit: string
- materialDescription: string
- hasSpecDocument: boolean
- requestedDeliveryDate: string
- specFiles: File[]
- isRepresentative: boolean // 대표 아이템 여부
+ id: string // 임시 ID for UI
+ prNumber: string
+ projectId?: number // 프로젝트 ID 추가
+ projectInfo?: string // 프로젝트 정보 (기존 호환성 유지)
+ shi?: string // SHI 정보 추가
+ quantity: string
+ quantityUnit: string
+ totalWeight: string
+ weightUnit: string
+ materialDescription: string
+ hasSpecDocument: boolean
+ requestedDeliveryDate: string
+ specFiles: File[]
+ isRepresentative: boolean // 대표 아이템 여부
+ // 가격 정보
+ annualUnitPrice: string
+ currency: string
+ // 자재 그룹 정보 (필수)
+ materialGroupNumber: string // 자재그룹코드 - 필수
+ materialGroupInfo: string // 자재그룹명 - 필수
+ // 자재 정보
+ materialNumber: string // 자재코드
+ materialInfo: string // 자재명
+ // 단위 정보
+ priceUnit: string // 가격단위
+ purchaseUnit: string // 구매단위
+ materialWeight: string // 자재순중량
+ // WBS 정보
+ wbsCode: string // WBS 코드
+ wbsName: string // WBS 명칭
+ // Cost Center 정보
+ costCenterCode: string // 코스트센터 코드
+ costCenterName: string // 코스트센터 명칭
+ // GL Account 정보
+ glAccountCode: string // GL 계정 코드
+ glAccountName: string // GL 계정 명칭
+ // 내정 정보
+ targetUnitPrice: string
+ targetAmount: string
+ targetCurrency: string
+ // 예산 정보
+ budgetAmount: string
+ budgetCurrency: string
+ // 실적 정보
+ actualAmount: string
+ actualCurrency: string
}
-// 탭 순서 정의
-const TAB_ORDER = ["basic", "schedule", "details", "manager"] as const
+const TAB_ORDER = ['basic', '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 [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // 추가
- const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null) // 추가
- const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false) // 닫기 확인 다이얼로그 상태
-
- // Procurement 데이터 상태들
- const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [shippingPlaces, setShippingPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [procurementLoading, setProcurementLoading] = React.useState(false)
-
- // 사양설명회 정보 상태
- 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: `pr-default`,
- prNumber: "",
- itemCode: "",
- itemInfo: "",
- quantity: "",
- quantityUnit: "EA",
- totalWeight: "",
- weightUnit: "KG",
- materialDescription: "",
- hasSpecDocument: false,
- requestedDeliveryDate: "",
- specFiles: [],
- isRepresentative: true, // 첫 번째 아이템은 대표 아이템
- }
- ])
-
- // 파일 첨부를 위해 선택된 아이템 ID
- const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
-
- // 입찰 조건 상태 (기본값 설정 포함)
- const [biddingConditions, setBiddingConditions] = React.useState({
- paymentTerms: "", // 초기값 빈값, 데이터 로드 후 설정
- taxConditions: "", // 초기값 빈값, 데이터 로드 후 설정
- incoterms: "", // 초기값 빈값, 데이터 로드 후 설정
- contractDeliveryDate: "",
- shippingPort: "",
- destinationPort: "",
- isPriceAdjustmentApplicable: false,
- sparePartOptions: "",
+ 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 [showSuccessDialog, setShowSuccessDialog] = React.useState(false)
+ const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null)
+ const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false)
+
+ const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+ const [incotermsOptions, setIncotermsOptions] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+ const [shippingPlaces, setShippingPlaces] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+ const [destinationPlaces, setDestinationPlaces] = React.useState<
+ Array<{ code: string; description: string }>
+ >([])
+
+ const [specMeetingInfo, setSpecMeetingInfo] =
+ React.useState<SpecificationMeetingInfo>({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ meetingFiles: [],
})
- // Procurement 데이터 로드 함수들
- const loadPaymentTerms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPaymentTermsForSelection();
- setPaymentTermsOptions(data);
- // 기본값 설정 로직: P008이 있으면 P008로, 없으면 첫 번째 항목으로 설정
- const setDefaultPaymentTerms = () => {
- const p008Exists = data.some(item => item.code === "P008");
- if (p008Exists) {
- setBiddingConditions(prev => ({ ...prev, paymentTerms: "P008" }));
- }
- };
-
- setDefaultPaymentTerms();
- } catch (error) {
- console.error("Failed to load payment terms:", error);
- toast.error("결제조건 목록을 불러오는데 실패했습니다.");
- // 에러 시 기본값 초기화
- if (biddingConditions.paymentTerms === "P008") {
- setBiddingConditions(prev => ({ ...prev, paymentTerms: "" }));
- }
- } finally {
- setProcurementLoading(false);
- }
- }, [biddingConditions.paymentTerms]);
-
- const loadIncoterms = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getIncotermsForSelection();
- setIncotermsOptions(data);
-
- // 기본값 설정 로직: DAP가 있으면 DAP로, 없으면 첫 번째 항목으로 설정
- const setDefaultIncoterms = () => {
- const dapExists = data.some(item => item.code === "DAP");
- if (dapExists) {
- setBiddingConditions(prev => ({ ...prev, incoterms: "DAP" }));
- }
- };
-
- setDefaultIncoterms();
- } catch (error) {
- console.error("Failed to load incoterms:", error);
- toast.error("운송조건 목록을 불러오는데 실패했습니다.");
- } finally {
- setProcurementLoading(false);
- }
- }, [biddingConditions.incoterms]);
-
- const loadShippingPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfShippingForSelection();
- setShippingPlaces(data);
- } catch (error) {
- console.error("Failed to load shipping places:", error);
- toast.error("선적지 목록을 불러오는데 실패했습니다.");
- } finally {
- setProcurementLoading(false);
- }
- }, []);
-
- const loadDestinationPlaces = React.useCallback(async () => {
- setProcurementLoading(true);
- try {
- const data = await getPlaceOfDestinationForSelection();
- setDestinationPlaces(data);
- } catch (error) {
- console.error("Failed to load destination places:", error);
- toast.error("하역지 목록을 불러오는데 실패했습니다.");
- } finally {
- setProcurementLoading(false);
- }
- }, []);
-
- // 다이얼로그 열릴 때 procurement 데이터 로드 및 기본값 설정
- React.useEffect(() => {
- if (open) {
- loadPaymentTerms();
- loadIncoterms();
- loadShippingPlaces();
- loadDestinationPlaces();
-
- // 세금조건 기본값 설정 (V1이 있는지 확인하고 설정)
- const v1Exists = TAX_CONDITIONS.some(item => item.code === "V1");
- if (v1Exists) {
- setBiddingConditions(prev => ({ ...prev, taxConditions: "V1" }));
- }
- }
- }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
-
-
- // 사양설명회 파일 추가
- const addMeetingFiles = (files: File[]) => {
- setSpecMeetingInfo(prev => ({
- ...prev,
- meetingFiles: [...prev.meetingFiles, ...files]
- }))
+ const [prItems, setPrItems] = React.useState<PRItemInfo[]>([
+ {
+ id: `pr-default`,
+ prNumber: '',
+ projectId: undefined,
+ projectInfo: '',
+ shi: '',
+ quantity: '',
+ quantityUnit: 'EA',
+ totalWeight: '',
+ weightUnit: 'KG',
+ materialDescription: '',
+ hasSpecDocument: false,
+ requestedDeliveryDate: '',
+ specFiles: [],
+ isRepresentative: true,
+ annualUnitPrice: '',
+ currency: 'KRW',
+ materialGroupNumber: '',
+ materialGroupInfo: '',
+ materialNumber: '',
+ materialInfo: '',
+ priceUnit: '',
+ purchaseUnit: '1',
+ materialWeight: '',
+ wbsCode: '',
+ wbsName: '',
+ costCenterCode: '',
+ costCenterName: '',
+ glAccountCode: '',
+ glAccountName: '',
+ targetUnitPrice: '',
+ targetAmount: '',
+ targetCurrency: 'KRW',
+ budgetAmount: '',
+ budgetCurrency: 'KRW',
+ actualAmount: '',
+ actualCurrency: 'KRW',
+ },
+ ])
+ const [selectedItemForFile, setSelectedItemForFile] = React.useState<string | null>(null)
+ const [quantityWeightMode, setQuantityWeightMode] = React.useState<'quantity' | 'weight'>('quantity')
+ const [costCenterDialogOpen, setCostCenterDialogOpen] = React.useState(false)
+ const [glAccountDialogOpen, setGlAccountDialogOpen] = React.useState(false)
+ const [wbsCodeDialogOpen, setWbsCodeDialogOpen] = React.useState(false)
+ const [materialGroupDialogOpen, setMaterialGroupDialogOpen] = React.useState(false)
+ const [materialDialogOpen, setMaterialDialogOpen] = React.useState(false)
+ const [biddingConditions, setBiddingConditions] = React.useState({
+ paymentTerms: '',
+ taxConditions: '',
+ incoterms: '',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+
+ // -- 데이터 로딩 및 상태 동기화 로직
+ const loadPaymentTerms = React.useCallback(async () => {
+ try {
+ const data = await getPaymentTermsForSelection()
+ setPaymentTermsOptions(data)
+ const p008Exists = data.some((item) => item.code === 'P008')
+ if (p008Exists) {
+ setBiddingConditions((prev) => ({ ...prev, paymentTerms: 'P008' }))
+ }
+ } catch (error) {
+ console.error('Failed to load payment terms:', error)
+ toast.error('결제조건 목록을 불러오는데 실패했습니다.')
}
-
- // 사양설명회 파일 제거
- const removeMeetingFile = (fileIndex: number) => {
- setSpecMeetingInfo(prev => ({
- ...prev,
- meetingFiles: prev.meetingFiles.filter((_, index) => index !== fileIndex)
- }))
+ }, [])
+
+ const loadIncoterms = React.useCallback(async () => {
+ try {
+ const data = await getIncotermsForSelection()
+ setIncotermsOptions(data)
+ const dapExists = data.some((item) => item.code === 'DAP')
+ if (dapExists) {
+ setBiddingConditions((prev) => ({ ...prev, incoterms: 'DAP' }))
+ }
+ } catch (error) {
+ console.error('Failed to load incoterms:', error)
+ toast.error('운송조건 목록을 불러오는데 실패했습니다.')
}
-
- // 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",
- biddingTypeCustom: "",
- awardCount: "single",
- contractStartDate: "",
- contractEndDate: "",
-
- 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 loadShippingPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfShippingForSelection()
+ setShippingPlaces(data)
+ } catch (error) {
+ console.error('Failed to load shipping places:', error)
+ toast.error('선적지 목록을 불러오는데 실패했습니다.')
}
-
- const goToPreviousTab = () => {
- if (!isFirstTab) {
- setActiveTab(TAB_ORDER[currentTabIndex - 1])
- }
+ }, [])
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ try {
+ const data = await getPlaceOfDestinationForSelection()
+ setDestinationPlaces(data)
+ } catch (error) {
+ console.error('Failed to load destination places:', error)
+ toast.error('하역지 목록을 불러오는데 실패했습니다.')
}
-
- // 탭별 validation 상태 체크
- const getTabValidationState = React.useCallback(() => {
- const formValues = form.getValues()
- const formErrors = form.formState.errors
-
- return {
- basic: {
- isValid: formValues.title.trim() !== "",
- hasErrors: !!(formErrors.title)
- },
- contract: {
- isValid: formValues.contractType &&
- formValues.biddingType &&
- formValues.awardCount &&
- formValues.contractStartDate &&
- formValues.contractEndDate &&
- formValues.currency,
- hasErrors: !!(formErrors.contractType || formErrors.biddingType || formErrors.awardCount || formErrors.contractStartDate || formErrors.contractEndDate || formErrors.currency)
- },
- schedule: {
- isValid: formValues.submissionStartDate &&
- formValues.submissionEndDate &&
- (!formValues.hasSpecificationMeeting ||
- (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)),
- hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate)
- },
- conditions: {
- isValid: biddingConditions.paymentTerms.trim() !== "" &&
- biddingConditions.taxConditions.trim() !== "" &&
- biddingConditions.incoterms.trim() !== "" &&
- biddingConditions.contractDeliveryDate.trim() !== "" &&
- biddingConditions.shippingPort.trim() !== "" &&
- biddingConditions.destinationPort.trim() !== "",
- hasErrors: false
- },
- details: {
- isValid: prItems.length > 0,
- hasErrors: false
- },
- manager: {
- isValid: true, // 담당자 정보는 자동 설정되므로 항상 유효
- hasErrors: !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone)
- }
- }
- }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, biddingConditions])
-
- const tabValidation = getTabValidationState()
-
- // 현재 탭이 유효한지 확인
- const isCurrentTabValid = () => {
- const validation = tabValidation[activeTab as keyof typeof tabValidation]
- return validation?.isValid ?? true
+ }, [])
+
+ React.useEffect(() => {
+ if (open) {
+ loadPaymentTerms()
+ loadIncoterms()
+ loadShippingPlaces()
+ loadDestinationPlaces()
+ const v1Exists = TAX_CONDITIONS.some((item) => item.code === 'V1')
+ if (v1Exists) {
+ setBiddingConditions((prev) => ({ ...prev, taxConditions: 'V1' }))
+ }
}
-
- // 대표 PR 번호 자동 계산
- const representativePrNumber = React.useMemo(() => {
- const representativeItem = prItems.find(item => item.isRepresentative)
- return representativeItem?.prNumber || ""
- }, [prItems])
-
- // 대표 품목명 자동 계산 (첫 번째 PR 아이템의 itemInfo)
- const representativeItemName = React.useMemo(() => {
- const representativeItem = prItems.find(item => item.isRepresentative)
- return representativeItem?.itemInfo || ""
- }, [prItems])
-
- // hasPrDocument 필드와 prNumber, itemName을 자동으로 업데이트
- React.useEffect(() => {
- form.setValue("hasPrDocument", hasPrDocuments)
- form.setValue("prNumber", representativePrNumber)
- form.setValue("itemName", representativeItemName)
- }, [hasPrDocuments, representativePrNumber, representativeItemName, 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 ('phone' in session.user && session.user.phone) {
- form.setValue("managerPhone", session.user.phone as string)
- }
- }
- }, [session, form])
-
- // PR 아이템 추가
- const addPRItem = () => {
- const newItem: PRItemInfo = {
- id: `pr-${Math.random().toString(36).substr(2, 9)}`,
- prNumber: "",
- itemCode: "",
- itemInfo: "",
- quantity: "",
- quantityUnit: "EA",
- totalWeight: "",
- weightUnit: "KG",
- materialDescription: "",
- hasSpecDocument: false,
- requestedDeliveryDate: "",
- specFiles: [],
- isRepresentative: prItems.length === 0, // 첫 번째 아이템은 자동으로 대표 아이템
- }
- setPrItems(prev => [...prev, newItem])
+ }, [open, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
+
+ 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,
+ projectName: '',
+ itemName: '',
+ title: '',
+ description: '',
+ content: '',
+ contractType: 'general',
+ biddingType: 'equipment',
+ biddingTypeCustom: '',
+ awardCount: 'single',
+ contractStartDate: '',
+ contractEndDate: '',
+ submissionStartDate: '',
+ submissionEndDate: '',
+ hasSpecificationMeeting: false,
+ prNumber: '',
+ currency: 'KRW',
+ status: 'bidding_generated',
+ isPublic: false,
+ purchasingOrganization: '',
+ 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])
}
+ }
- // PR 아이템 제거
- const removePRItem = (id: string) => {
- // 최소 하나의 아이템은 유지해야 함
- if (prItems.length <= 1) {
- toast.error("최소 하나의 품목이 필요합니다.")
- return
- }
-
- 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)
- }
+ const goToPreviousTab = () => {
+ if (!isFirstTab) {
+ setActiveTab(TAB_ORDER[currentTabIndex - 1])
}
-
- // PR 아이템 업데이트
- const updatePRItem = (id: string, updates: Partial<PRItemInfo>) => {
- setPrItems(prev => prev.map(item =>
- item.id === id ? { ...item, ...updates } : item
- ))
+ }
+
+ const getTabValidationState = React.useCallback(() => {
+ const formValues = form.getValues()
+ const formErrors = form.formState.errors
+
+ return {
+ basic: {
+ isValid: formValues.title.trim() !== '',
+ hasErrors: !!formErrors.title,
+ },
+ schedule: {
+ isValid:
+ formValues.submissionStartDate &&
+ formValues.submissionEndDate &&
+ (!formValues.hasSpecificationMeeting ||
+ (specMeetingInfo.meetingDate && specMeetingInfo.location && specMeetingInfo.contactPerson)),
+ hasErrors: !!(formErrors.submissionStartDate || formErrors.submissionEndDate),
+ },
+ details: {
+ // 임시로 자재그룹코드 필수 체크 해제
+ // isValid: prItems.length > 0 && prItems.every(item => item.materialGroupNumber.trim() !== ''),
+ isValid: prItems.length > 0,
+ hasErrors: false,
+ },
+ manager: {
+ // 임시로 담당자 필수 체크 해제
+ isValid: true,
+ hasErrors: false, // !!(formErrors.managerName || formErrors.managerEmail || formErrors.managerPhone),
+ },
}
-
- // 대표 아이템 설정 (하나만 선택 가능)
- const setRepresentativeItem = (id: string) => {
- setPrItems(prev => prev.map(item => ({
- ...item,
- isRepresentative: item.id === id
- })))
+ }, [form, specMeetingInfo.meetingDate, specMeetingInfo.location, specMeetingInfo.contactPerson, prItems])
+
+ const tabValidation = getTabValidationState()
+
+ const isCurrentTabValid = () => {
+ const validation = tabValidation[activeTab as keyof typeof tabValidation]
+ return validation?.isValid ?? true
+ }
+
+ const representativePrNumber = React.useMemo(() => {
+ const representativeItem = prItems.find((item) => item.isRepresentative)
+ return representativeItem?.prNumber || ''
+ }, [prItems])
+
+ const representativeItemName = React.useMemo(() => {
+ const representativeItem = prItems.find((item) => item.isRepresentative)
+ return representativeItem?.materialGroupInfo || ''
+ }, [prItems])
+
+ React.useEffect(() => {
+ form.setValue('hasPrDocument', hasPrDocuments)
+ form.setValue('prNumber', representativePrNumber)
+ form.setValue('itemName', representativeItemName)
+ }, [hasPrDocuments, representativePrNumber, representativeItemName, form])
+
+ const addPRItem = () => {
+ const newItem: PRItemInfo = {
+ id: `pr-${Math.random().toString(36).substr(2, 9)}`,
+ prNumber: '',
+ projectId: undefined,
+ projectInfo: '',
+ shi: '',
+ quantity: '',
+ quantityUnit: 'EA',
+ totalWeight: '',
+ weightUnit: 'KG',
+ materialDescription: '',
+ hasSpecDocument: false,
+ requestedDeliveryDate: '',
+ specFiles: [],
+ isRepresentative: prItems.length === 0,
+ annualUnitPrice: '',
+ currency: 'KRW',
+ materialGroupNumber: '',
+ materialGroupInfo: '',
+ materialNumber: '',
+ materialInfo: '',
+ priceUnit: '',
+ purchaseUnit: '1',
+ materialWeight: '',
+ wbsCode: '',
+ wbsName: '',
+ costCenterCode: '',
+ costCenterName: '',
+ glAccountCode: '',
+ glAccountName: '',
+ targetUnitPrice: '',
+ targetAmount: '',
+ targetCurrency: 'KRW',
+ budgetAmount: '',
+ budgetCurrency: 'KRW',
+ actualAmount: '',
+ actualCurrency: 'KRW',
}
+ setPrItems((prev) => [...prev, newItem])
+ }
- // 스펙 파일 추가
- const addSpecFiles = (itemId: string, files: File[]) => {
- updatePRItem(itemId, {
- specFiles: [...(prItems.find(item => item.id === itemId)?.specFiles || []), ...files]
- })
- // 파일 추가 후 선택 해제
- setSelectedItemForFile(null)
+ const removePRItem = (id: string) => {
+ if (prItems.length <= 1) {
+ toast.error('최소 하나의 품목이 필요합니다.')
+ return
}
- // 스펙 파일 제거
- 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 })
+ 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)
+ }
+ }
+
+ const updatePRItem = (id: string, updates: Partial<PRItemInfo>) => {
+ setPrItems((prev) =>
+ prev.map((item) => {
+ if (item.id === id) {
+ const updatedItem = { ...item, ...updates }
+ // 내정단가, 수량, 중량, 구매단위가 변경되면 내정금액 재계산
+ if (updates.targetUnitPrice || updates.quantity || updates.totalWeight || updates.purchaseUnit) {
+ updatedItem.targetAmount = calculateTargetAmount(updatedItem)
+ }
+ return updatedItem
}
+ return item
+ })
+ )
+ }
+
+ const setRepresentativeItem = (id: string) => {
+ setPrItems((prev) =>
+ prev.map((item) => ({
+ ...item,
+ isRepresentative: item.id === id,
+ }))
+ )
+ }
+
+ const handleQuantityWeightModeChange = (mode: 'quantity' | 'weight') => {
+ setQuantityWeightMode(mode)
+ }
+
+ const calculateTargetAmount = (item: PRItemInfo) => {
+ const unitPrice = parseFloat(item.targetUnitPrice) || 0
+ const purchaseUnit = parseFloat(item.purchaseUnit) || 1 // 기본값 1
+ let amount = 0
+
+ if (quantityWeightMode === 'quantity') {
+ const quantity = parseFloat(item.quantity) || 0
+ // (수량 / 구매단위) * 내정단가
+ amount = (quantity / purchaseUnit) * unitPrice
+ } else {
+ const weight = parseFloat(item.totalWeight) || 0
+ // (중량 / 구매단위) * 내정단가
+ amount = (weight / purchaseUnit) * unitPrice
}
- // ✅ 프로젝트 선택 핸들러
- const handleProjectSelect = React.useCallback((project: { id: number; code: string; name: string } | null) => {
- if (project) {
- form.setValue("projectId", project.id)
- } else {
- form.setValue("projectId", 0)
- }
- }, [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("제출 시작일시와 마감일시를 입력해주세요")
- }
- } else if (activeTab === "conditions") {
- toast.error("입찰 조건을 모두 입력해주세요 (지급조건, 세금조건, 운송조건, 계약납품일)")
- } else if (activeTab === "details") {
- toast.error("품목정보, 수량/단위 또는 중량/중량단위를 입력해주세요")
- }
- return
- }
+ // 소수점 버림
+ return Math.floor(amount).toString()
+ }
- goToNextTab()
+ 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 })
}
-
- // 폼 제출
- 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
- }
+ }
+
+ const handleNextClick = () => {
+ if (!isCurrentTabValid()) {
+ if (activeTab === 'basic') {
+ toast.error('기본 정보를 모두 입력해주세요.')
+ } else if (activeTab === 'schedule') {
+ if (form.watch('hasSpecificationMeeting')) {
+ toast.error('사양설명회 필수 정보를 입력해주세요.')
+ } else {
+ toast.error('제출 시작일시와 마감일시를 입력해주세요.')
}
+ } else if (activeTab === 'details') {
+ toast.error('최소 하나의 아이템이 필요하며, 모든 아이템에 자재그룹코드가 필수입니다.')
+ }
+ 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 : [],
- biddingConditions: biddingConditions,
- }
-
- const result = await createBidding(extendedData, userId)
-
- if (result.success) {
- toast.success((result as { success: true; message: string }).message || "입찰이 성공적으로 생성되었습니다.")
- setOpen(false)
- router.refresh()
-
- // 생성된 입찰 상세페이지로 이동할지 묻기
- if (result.success && 'data' in result && result.data?.id) {
- setCreatedBiddingId(result.data.id)
- setShowSuccessDialog(true)
- }
- } else {
- const errorMessage = result.success === false && 'error' in result ? result.error : "입찰 생성에 실패했습니다."
- toast.error(errorMessage)
- }
- } catch (error) {
- console.error("Error creating bidding:", error)
- toast.error("입찰 생성 중 오류가 발생했습니다.")
- } finally {
- setIsSubmitting(false)
- }
+ 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
+ }
}
- // 폼 및 상태 초기화 함수
- const resetAllStates = React.useCallback(() => {
- // 폼 초기화
- form.reset({
- revision: 0,
- projectId: 0,
- projectName: "",
- itemName: "",
- title: "",
- description: "",
- content: "",
- contractType: "general",
- biddingType: "equipment",
- biddingTypeCustom: "",
- awardCount: "single",
- contractStartDate: "",
- contractEndDate: "",
- submissionStartDate: "",
- submissionEndDate: "",
- hasSpecificationMeeting: false,
- prNumber: "",
- currency: "KRW",
- status: "bidding_generated",
- isPublic: false,
- managerName: "",
- managerEmail: "",
- managerPhone: "",
- remarks: "",
- })
-
- // 추가 상태들 초기화
- setSpecMeetingInfo({
- meetingDate: "",
- meetingTime: "",
- location: "",
- address: "",
- contactPerson: "",
- contactPhone: "",
- contactEmail: "",
- agenda: "",
- materials: "",
- notes: "",
- isRequired: false,
- meetingFiles: [],
- })
- setPrItems([
- {
- id: `pr-default`,
- prNumber: "",
- itemCode: "",
- itemInfo: "",
- quantity: "",
- quantityUnit: "EA",
- totalWeight: "",
- weightUnit: "KG",
- materialDescription: "",
- hasSpecDocument: false,
- requestedDeliveryDate: "",
- specFiles: [],
- isRepresentative: true, // 첫 번째 아이템은 대표 아이템
+ setIsSubmitting(true)
+ try {
+ const userId = session?.user?.id?.toString() || '1'
+
+ const extendedData = {
+ ...data,
+ hasPrDocument: hasPrDocuments,
+ prNumber: representativePrNumber,
+ specificationMeeting: data.hasSpecificationMeeting
+ ? {
+ ...specMeetingInfo,
+ meetingFiles: specMeetingInfo.meetingFiles,
}
- ])
- setSelectedItemForFile(null)
- setBiddingConditions({
- paymentTerms: "",
- taxConditions: "",
- incoterms: "",
- contractDeliveryDate: "",
- shippingPort: "",
- destinationPort: "",
- isPriceAdjustmentApplicable: false,
- sparePartOptions: "",
- })
- setActiveTab("basic")
- setShowSuccessDialog(false) // 추가
- setCreatedBiddingId(null) // 추가
- }, [form])
-
- // 다이얼로그 핸들러
- function handleDialogOpenChange(nextOpen: boolean) {
- if (!nextOpen) {
- // 닫으려 할 때 확인 창을 먼저 띄움
- setShowCloseConfirmDialog(true)
- } else {
- // 열 때는 바로 적용
- setOpen(nextOpen)
+ : null,
+ prItems: prItems.length > 0 ? prItems : [],
+ biddingConditions: biddingConditions,
+ }
+
+ const result = await createBidding(extendedData, userId)
+
+ if (result.success) {
+ toast.success(
+ (result as { success: true; message: string }).message || '입찰이 성공적으로 생성되었습니다.'
+ )
+ setOpen(false)
+ router.refresh()
+ if (result.success && 'data' in result && result.data?.id) {
+ setCreatedBiddingId(result.data.id)
+ setShowSuccessDialog(true)
}
+ } else {
+ const errorMessage =
+ result.success === false && 'error' in result ? result.error : '입찰 생성에 실패했습니다.'
+ toast.error(errorMessage)
+ }
+ } 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',
+ biddingTypeCustom: '',
+ awardCount: 'single',
+ contractStartDate: '',
+ contractEndDate: '',
+ submissionStartDate: '',
+ submissionEndDate: '',
+ hasSpecificationMeeting: false,
+ prNumber: '',
+ currency: 'KRW',
+ status: 'bidding_generated',
+ isPublic: false,
+ purchasingOrganization: '',
+ managerName: '',
+ managerEmail: '',
+ managerPhone: '',
+ remarks: '',
+ })
- // 닫기 확인 핸들러
- const handleCloseConfirm = (confirmed: boolean) => {
- setShowCloseConfirmDialog(false)
- if (confirmed) {
- // 사용자가 "예"를 선택한 경우 실제로 닫기
- resetAllStates()
- setOpen(false)
- }
- // "아니오"를 선택한 경우는 아무것도 하지 않음 (다이얼로그 유지)
+ setSpecMeetingInfo({
+ meetingDate: '',
+ meetingTime: '',
+ location: '',
+ address: '',
+ contactPerson: '',
+ contactPhone: '',
+ contactEmail: '',
+ agenda: '',
+ materials: '',
+ notes: '',
+ isRequired: false,
+ meetingFiles: [],
+ })
+ setPrItems([
+ {
+ id: `pr-default`,
+ prNumber: '',
+ projectId: undefined,
+ projectInfo: '',
+ shi: '',
+ quantity: '',
+ quantityUnit: 'EA',
+ totalWeight: '',
+ weightUnit: 'KG',
+ materialDescription: '',
+ hasSpecDocument: false,
+ requestedDeliveryDate: '',
+ specFiles: [],
+ isRepresentative: true,
+ annualUnitPrice: '',
+ currency: 'KRW',
+ materialGroupNumber: '',
+ materialGroupInfo: '',
+ materialNumber: '',
+ materialInfo: '',
+ priceUnit: '',
+ purchaseUnit: '',
+ materialWeight: '',
+ wbsCode: '',
+ wbsName: '',
+ costCenterCode: '',
+ costCenterName: '',
+ glAccountCode: '',
+ glAccountName: '',
+ targetUnitPrice: '',
+ targetAmount: '',
+ targetCurrency: 'KRW',
+ budgetAmount: '',
+ budgetCurrency: 'KRW',
+ actualAmount: '',
+ actualCurrency: 'KRW',
+ },
+ ])
+ setSelectedItemForFile(null)
+ setCostCenterDialogOpen(false)
+ setGlAccountDialogOpen(false)
+ setWbsCodeDialogOpen(false)
+ setMaterialGroupDialogOpen(false)
+ setMaterialDialogOpen(false)
+ setBiddingConditions({
+ paymentTerms: '',
+ taxConditions: '',
+ incoterms: '',
+ incotermsOption: '',
+ contractDeliveryDate: '',
+ shippingPort: '',
+ destinationPort: '',
+ isPriceAdjustmentApplicable: false,
+ sparePartOptions: '',
+ })
+ setActiveTab('basic')
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }, [form])
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ setShowCloseConfirmDialog(true)
+ } else {
+ setOpen(nextOpen)
}
+ }
- // 입찰 생성 버튼 클릭 핸들러 추가
- const handleCreateBidding = () => {
- // 마지막 탭 validation 체크
- if (!isCurrentTabValid()) {
- toast.error("필수 정보를 모두 입력해주세요.")
- return
- }
-
- // 수동으로 폼 제출
- form.handleSubmit(onSubmit)()
+ const handleCloseConfirm = (confirmed: boolean) => {
+ setShowCloseConfirmDialog(false)
+ if (confirmed) {
+ resetAllStates()
+ setOpen(false)
}
+ }
- // 성공 다이얼로그 핸들러들
- const handleNavigateToDetail = () => {
- if (createdBiddingId) {
- router.push(`/evcp/bid/${createdBiddingId}`)
- }
- setShowSuccessDialog(false)
- setCreatedBiddingId(null)
+ const handleCreateBidding = () => {
+ if (!isCurrentTabValid()) {
+ toast.error('필수 정보를 모두 입력해주세요.')
+ return
}
- const handleStayOnPage = () => {
- setShowSuccessDialog(false)
- setCreatedBiddingId(null)
+ form.handleSubmit(onSubmit)()
+ }
+
+ const handleNavigateToDetail = () => {
+ if (createdBiddingId) {
+ router.push(`/evcp/bid/${createdBiddingId}`)
}
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }
+ const handleStayOnPage = () => {
+ setShowSuccessDialog(false)
+ setCreatedBiddingId(null)
+ }
+ // PR 아이템 테이블 렌더링
+ const renderPrItemsTable = () => {
return (
- <>
- <Dialog open={open} onOpenChange={handleDialogOpenChange}>
- <DialogTrigger asChild>
- <Button variant="default" size="sm">
- <Plus className="mr-2 h-4 w-4" />
- 신규 입찰
+ <div className="border rounded-lg overflow-hidden">
+ <div className="overflow-x-auto">
+ <table className="w-full border-collapse">
+ <thead className="bg-muted/50">
+ <tr>
+ <th className="sticky left-0 z-10 bg-muted/50 border-r px-2 py-3 text-left text-xs font-medium min-w-[50px]">
+ <span className="sr-only">대표</span>
+ </th>
+ <th className="sticky left-[50px] z-10 bg-muted/50 border-r px-3 py-3 text-left text-xs font-medium min-w-[40px]">
+ #
+ </th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">프로젝트코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">프로젝트명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재그룹코드 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재그룹명 <span className="text-red-500">*</span></th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">자재코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">자재명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">수량</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">구매단위</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정단가</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">내정금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">내정통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">예산금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">예산통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">실적금액</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[80px]">실적통화</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[300px]">WBS코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">WBS명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">코스트센터코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">코스트센터명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">GL계정코드</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[150px]">GL계정명</th>
+ <th className="border-r px-3 py-3 text-left text-xs font-medium min-w-[120px]">납품요청일</th>
+ <th className="sticky right-0 z-10 bg-muted/50 border-l px-3 py-3 text-center text-xs font-medium min-w-[100px]">
+ 액션
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {prItems.map((item, index) => (
+ <tr key={item.id} className="border-t hover:bg-muted/30">
+ <td className="sticky left-0 z-10 bg-background border-r px-2 py-2 text-center">
+ <Checkbox
+ checked={item.isRepresentative}
+ onCheckedChange={() => setRepresentativeItem(item.id)}
+ disabled={prItems.length <= 1 && item.isRepresentative}
+ title="대표 아이템"
+ />
+ </td>
+ <td className="sticky left-[50px] z-10 bg-background border-r px-3 py-2 text-xs text-muted-foreground">
+ {index + 1}
+ </td>
+ <td className="border-r px-3 py-2">
+ <ProjectSelector
+ selectedProjectId={item.projectId || null}
+ onProjectSelect={(project) => {
+ updatePRItem(item.id, {
+ projectId: project.id,
+ projectInfo: project.projectName
+ })
+ }}
+ placeholder="프로젝트 선택"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="프로젝트명"
+ value={item.projectInfo || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupNumber || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupNumber ? {
+ materialGroupCode: item.materialGroupNumber,
+ materialGroupDescription: item.materialGroupInfo,
+ displayText: `${item.materialGroupNumber} - ${item.materialGroupInfo}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialGroupNumber: material.materialGroupCode,
+ materialGroupInfo: material.materialGroupDescription
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialGroupNumber: '',
+ materialGroupInfo: ''
+ })
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재그룹명"
+ value={item.materialGroupInfo}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <MaterialSelectorDialogSingle
+ triggerLabel={item.materialNumber || "자재 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialNumber ? {
+ materialCode: item.materialNumber,
+ materialName: item.materialInfo,
+ displayText: `${item.materialNumber} - ${item.materialInfo}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updatePRItem(item.id, {
+ materialNumber: material.materialCode,
+ materialInfo: material.materialName
+ })
+ } else {
+ updatePRItem(item.id, {
+ materialNumber: '',
+ materialInfo: ''
+ })
+ }
+ }}
+ title="자재 선택"
+ description="자재를 검색하고 선택해주세요."
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="자재명"
+ value={item.materialInfo}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Input
+ type="number"
+ min="0"
+ placeholder="수량"
+ value={item.quantity}
+ onChange={(e) => updatePRItem(item.id, { quantity: e.target.value })}
+ className="h-8 text-xs"
+ />
+ ) : (
+ <Input
+ type="number"
+ min="0"
+ placeholder="중량"
+ value={item.totalWeight}
+ onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
+ className="h-8 text-xs"
+ />
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ {quantityWeightMode === 'quantity' ? (
+ <Select
+ value={item.quantityUnit}
+ onValueChange={(value) => updatePRItem(item.id, { quantityUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <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>
+ ) : (
+ <Select
+ value={item.weightUnit}
+ onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KG">KG</SelectItem>
+ <SelectItem value="TON">TON</SelectItem>
+ <SelectItem value="G">G</SelectItem>
+ <SelectItem value="LB">LB</SelectItem>
+ </SelectContent>
+ </Select>
+ )}
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="1"
+ step="1"
+ placeholder="구매단위"
+ value={item.purchaseUnit || ''}
+ onChange={(e) => updatePRItem(item.id, { purchaseUnit: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정단가"
+ value={item.targetUnitPrice || ''}
+ onChange={(e) => updatePRItem(item.id, { targetUnitPrice: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="내정금액"
+ readOnly
+ value={item.targetAmount || ''}
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.targetCurrency}
+ onValueChange={(value) => updatePRItem(item.id, { targetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="예산금액"
+ value={item.budgetAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { budgetAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.budgetCurrency}
+ onValueChange={(value) => updatePRItem(item.id, { budgetCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="number"
+ min="0"
+ step="1"
+ placeholder="실적금액"
+ value={item.actualAmount || ''}
+ onChange={(e) => updatePRItem(item.id, { actualAmount: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Select
+ value={item.actualCurrency}
+ onValueChange={(value) => updatePRItem(item.id, { actualCurrency: value })}
+ >
+ <SelectTrigger className="h-8 text-xs">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="KRW">KRW</SelectItem>
+ <SelectItem value="USD">USD</SelectItem>
+ <SelectItem value="EUR">EUR</SelectItem>
+ <SelectItem value="JPY">JPY</SelectItem>
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => setWbsCodeDialogOpen(true)}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.wbsCode ? (
+ <span className="truncate">
+ {`${item.wbsCode}${item.wbsName ? ` - ${item.wbsName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">WBS 코드 선택</span>
+ )}
+ </Button>
+ <WbsCodeSingleSelector
+ open={wbsCodeDialogOpen}
+ onOpenChange={setWbsCodeDialogOpen}
+ selectedCode={item.wbsCode ? {
+ PROJ_NO: '',
+ WBS_ELMT: item.wbsCode,
+ WBS_ELMT_NM: item.wbsName || '',
+ WBS_LVL: ''
+ } : undefined}
+ onCodeSelect={(wbsCode) => {
+ updatePRItem(item.id, {
+ wbsCode: wbsCode.WBS_ELMT,
+ wbsName: wbsCode.WBS_ELMT_NM
+ })
+ setWbsCodeDialogOpen(false)
+ }}
+ title="WBS 코드 선택"
+ description="WBS 코드를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="WBS명"
+ value={item.wbsName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => setCostCenterDialogOpen(true)}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.costCenterCode ? (
+ <span className="truncate">
+ {`${item.costCenterCode}${item.costCenterName ? ` - ${item.costCenterName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">코스트센터 선택</span>
+ )}
</Button>
- </DialogTrigger>
- <DialogContent className="max-w-7xl h-[90vh] p-0 flex flex-col">
- {/* 고정 헤더 */}
- <div className="flex-shrink-0 p-6 border-b">
- <DialogHeader>
- <DialogTitle>신규 입찰 생성</DialogTitle>
- <DialogDescription>
- 새로운 입찰을 생성합니다. 단계별로 정보를 입력해주세요.
- </DialogDescription>
- </DialogHeader>
+ <CostCenterSingleSelector
+ open={costCenterDialogOpen}
+ onOpenChange={setCostCenterDialogOpen}
+ selectedCode={item.costCenterCode ? {
+ KOSTL: item.costCenterCode,
+ KTEXT: '',
+ LTEXT: item.costCenterName || '',
+ DATAB: '',
+ DATBI: ''
+ } : undefined}
+ onCodeSelect={(costCenter) => {
+ updatePRItem(item.id, {
+ costCenterCode: costCenter.KOSTL,
+ costCenterName: costCenter.LTEXT
+ })
+ setCostCenterDialogOpen(false)
+ }}
+ title="코스트센터 선택"
+ description="코스트센터를 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="코스트센터명"
+ value={item.costCenterName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Button
+ variant="outline"
+ onClick={() => setGlAccountDialogOpen(true)}
+ className="w-full justify-start h-8 text-xs"
+ >
+ {item.glAccountCode ? (
+ <span className="truncate">
+ {`${item.glAccountCode}${item.glAccountName ? ` - ${item.glAccountName}` : ''}`}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">GL계정 선택</span>
+ )}
+ </Button>
+ <GlAccountSingleSelector
+ open={glAccountDialogOpen}
+ onOpenChange={setGlAccountDialogOpen}
+ selectedCode={item.glAccountCode ? {
+ SAKNR: item.glAccountCode,
+ FIPEX: '',
+ TEXT1: item.glAccountName || ''
+ } : undefined}
+ onCodeSelect={(glAccount) => {
+ updatePRItem(item.id, {
+ glAccountCode: glAccount.SAKNR,
+ glAccountName: glAccount.TEXT1
+ })
+ setGlAccountDialogOpen(false)
+ }}
+ title="GL 계정 선택"
+ description="GL 계정을 선택하세요"
+ showConfirmButtons={false}
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ placeholder="GL계정명"
+ value={item.glAccountName || ''}
+ readOnly
+ className="h-8 text-xs bg-muted/50"
+ />
+ </td>
+ <td className="border-r px-3 py-2">
+ <Input
+ type="date"
+ value={item.requestedDeliveryDate}
+ onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
+ className="h-8 text-xs"
+ />
+ </td>
+ <td className="sticky right-0 z-10 bg-background border-l px-3 py-2">
+ <div className="flex items-center justify-center gap-1">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => {
+ const fileInput = document.createElement('input')
+ fileInput.type = 'file'
+ fileInput.multiple = true
+ fileInput.accept = '.pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg'
+ fileInput.onchange = (e) => {
+ const files = Array.from((e.target as HTMLInputElement).files || [])
+ if (files.length > 0) {
+ addSpecFiles(item.id, files)
+ }
+ }
+ fileInput.click()
+ }}
+ className="h-7 w-7 p-0"
+ title="파일 첨부"
+ >
+ <Paperclip className="h-3.5 w-3.5" />
+ {item.specFiles.length > 0 && (
+ <span className="absolute -top-1 -right-1 bg-primary text-primary-foreground rounded-full w-4 h-4 text-[10px] flex items-center justify-center">
+ {item.specFiles.length}
+ </span>
+ )}
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removePRItem(item.id)}
+ disabled={prItems.length <= 1}
+ className="h-7 w-7 p-0"
+ title="품목 삭제"
+ >
+ <Trash2 className="h-3.5 w-3.5" />
+ </Button>
</div>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1 min-h-0"
- id="create-bidding-form"
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+
+ {/* 첨부된 파일 목록 표시 */}
+ {prItems.some(item => item.specFiles.length > 0) && (
+ <div className="border-t p-4 bg-muted/20">
+ <Label className="text-sm font-medium mb-2 block">첨부된 스펙 파일</Label>
+ <div className="space-y-3">
+ {prItems.map((item, index) => (
+ item.specFiles.length > 0 && (
+ <div key={item.id} className="space-y-1">
+ <div className="text-xs font-medium text-muted-foreground">
+ {item.materialGroupInfo || item.materialGroupNumber || `ITEM-${index + 1}`}
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {item.specFiles.map((file, fileIndex) => (
+ <div
+ key={fileIndex}
+ className="inline-flex items-center gap-1 px-2 py-1 bg-background border rounded text-xs"
+ >
+ <Paperclip className="h-3 w-3" />
+ <span className="max-w-[200px] truncate">{file.name}</span>
+ <span className="text-muted-foreground">
+ ({(file.size / 1024 / 1024).toFixed(2)} MB)
+ </span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeSpecFile(item.id, fileIndex)}
+ className="h-4 w-4 p-0 ml-1 hover:bg-destructive/20"
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )
+ }
+
+
+ return (
+ <>
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 신규 입찰
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="h-[90vh] p-0 flex flex-col" style={{ maxWidth: '1400px' }}>
+ {/* 고정 헤더 */}
+ <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={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col">
+ {/* 탭 리스트 */}
+ <div className="px-6">
+ <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto">
+ {TAB_ORDER.map((tab) => (
+ <button
+ key={tab}
+ type="button"
+ onClick={() => setActiveTab(tab)}
+ className={cn(
+ 'relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0',
+ activeTab === tab ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
+ )}
>
- {/* 탭 영역 */}
- <div className="flex-1 overflow-hidden">
- <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as TabType)} className="h-full flex flex-col">
- <div className="px-6">
- <div className="flex space-x-1 bg-muted p-1 rounded-lg overflow-x-auto">
- <button
- type="button"
- onClick={() => setActiveTab("basic")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "basic"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 기본정보
- {!tabValidation.basic.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("schedule")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "schedule"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 입찰계획
- {!tabValidation.schedule.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("details")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "details"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 세부내역
- {!tabValidation.details.isValid && (
- <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
- )}
- </button>
- <button
- type="button"
- onClick={() => setActiveTab("manager")}
- className={`relative px-3 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap flex-shrink-0 ${
- activeTab === "manager"
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground"
- }`}
- >
- 담당자
- </button>
- </div>
- </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>
- 프로젝트
- </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>
- )}
- />
-
- {/* 계약 정보 섹션 */}
- <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>
- )}
- />
-
- {/* 기타 입찰유형 직접입력 */}
- {form.watch("biddingType") === "other" && (
- <FormField
- control={form.control}
- name="biddingTypeCustom"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 기타 입찰유형 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input
- placeholder="직접 입력하세요"
- {...field}
- />
- </FormControl>
- <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="contractStartDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약 시작일 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 계약 종료일 */}
- <FormField
- control={form.control}
- name="contractEndDate"
- render={({ field }) => (
- <FormItem>
- <FormLabel>계약 종료일 <span className="text-red-500">*</span></FormLabel>
- <FormControl>
- <Input
- type="date"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* 통화 선택만 유지 */}
- <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>
- )}
- />
-
- {/* 입찰 조건 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle>입찰 조건</CardTitle>
- <p className="text-sm text-muted-foreground">
- 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요
- </p>
- </CardHeader>
- <CardContent className="space-y-6">
- <div className="grid grid-cols-2 gap-6">
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 지급조건 <span className="text-red-500">*</span>
- </label>
- <Select
- value={biddingConditions.paymentTerms}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- paymentTerms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="지급조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 세금조건 <span className="text-red-500">*</span>
- </label>
- <Select
- value={biddingConditions.taxConditions}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- taxConditions: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="세금조건 선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 운송조건(인코텀즈) <span className="text-red-500">*</span>
- </label>
- <Select
- value={biddingConditions.incoterms}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- incoterms: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="인코텀즈 선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">
- 계약 납품일 <span className="text-red-500">*</span>
- </label>
- <Input
- type="date"
- value={biddingConditions.contractDeliveryDate}
- onChange={(e) => setBiddingConditions(prev => ({
- ...prev,
- contractDeliveryDate: e.target.value
- }))}
- />
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">선적지 (선택사항)</label>
- <Select
- value={biddingConditions.shippingPort}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- shippingPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="선적지 선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">하역지 (선택사항)</label>
- <Select
- value={biddingConditions.destinationPort}
- onValueChange={(value) => setBiddingConditions(prev => ({
- ...prev,
- destinationPort: value
- }))}
- >
- <SelectTrigger>
- <SelectValue placeholder="하역지 선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <div className="flex items-center space-x-2">
- <Switch
- id="price-adjustment"
- checked={biddingConditions.isPriceAdjustmentApplicable}
- onCheckedChange={(checked) => setBiddingConditions(prev => ({
- ...prev,
- isPriceAdjustmentApplicable: checked
- }))}
- />
- <label htmlFor="price-adjustment" className="text-sm font-medium">
- 연동제 적용 요건 문의
- </label>
- </div>
-
- <div className="space-y-2">
- <label className="text-sm font-medium">스페어파트 옵션</label>
- <Textarea
- placeholder="스페어파트 관련 옵션을 입력하세요"
- value={biddingConditions.sparePartOptions}
- onChange={(e) => setBiddingConditions(prev => ({
- ...prev,
- sparePartOptions: e.target.value
- }))}
- rows={3}
- />
- </div>
- </CardContent>
- </Card>
- </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>
-
- {/* 긴급 입찰 설정 */}
- <Card>
- <CardHeader>
- <CardTitle>긴급 입찰 설정</CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- <FormField
- control={form.control}
- name="isUrgent"
- 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>
- )}
- />
- </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">
- 최소 하나의 품목을 입력해야 합니다
- </p>
- <p className="text-xs text-amber-600 mt-1">
- 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
- </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-[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"
- min="0"
- 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="number"
- min="0"
- placeholder="중량"
- value={item.totalWeight}
- onChange={(e) => updatePRItem(item.id, { totalWeight: e.target.value })}
- className="h-8"
- />
- </TableCell>
- <TableCell>
- <Select
- value={item.weightUnit}
- onValueChange={(value) => updatePRItem(item.id, { weightUnit: value })}
- >
- <SelectTrigger className="h-8">
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="KG">KG</SelectItem>
- <SelectItem value="TON">TON</SelectItem>
- <SelectItem value="G">G</SelectItem>
- <SelectItem value="LB">LB</SelectItem>
- </SelectContent>
- </Select>
- </TableCell>
- <TableCell>
- <Input
- type="date"
- value={item.requestedDeliveryDate}
- onChange={(e) => updatePRItem(item.id, { requestedDeliveryDate: e.target.value })}
- className="h-8"
- placeholder="납품요청일"
- />
- </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)}
- disabled={prItems.length <= 1}
- className="h-8 w-8 p-0"
- title={prItems.length <= 1 ? "최소 하나의 품목이 필요합니다" : "품목 삭제"}
- >
- <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}
- className="flex items-center justify-between p-3 border rounded-lg mb-2"
- >
- <div className="flex items-center gap-3 flex-1">
- <FileListIcon className="flex-shrink-0" />
- <FileListInfo className="flex items-center gap-3 flex-1">
- <FileListName className="font-medium text-gray-700">
- {file.name}
- </FileListName>
- <FileListSize className="text-sm text-gray-500">
- {file.size}
- </FileListSize>
- </FileListInfo>
- </div>
- <FileListAction className="flex-shrink-0">
- <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 mx-auto"
- >
- <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>
+ {tab === 'basic' && '기본 정보'}
+ {tab === 'schedule' && '입찰 계획'}
+ {tab === 'details' && '세부 내역'}
+ {tab === 'manager' && '담당자'}
+ {!tabValidation[tab].isValid && (
+ <span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"></span>
+ )}
+ </button>
+ ))}
+ </div>
+ </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="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>
+ )}
+ />
+ <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>
+ )}
+ />
+ {form.watch('biddingType') === 'other' && (
+ <FormField
+ control={form.control}
+ name="biddingTypeCustom"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기타 입찰유형 <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input placeholder="직접 입력하세요" {...field} />
+ </FormControl>
+ <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="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>
+ <div className="grid grid-cols-2 gap-6">
+ <FormField
+ control={form.control}
+ name="contractStartDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약시작일</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="contractEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약종료일</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ <FormField
+ control={form.control}
+ name="purchasingOrganization"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구매조직</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="구매조직 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="조선">조선</SelectItem>
+ <SelectItem value="해양">해양</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </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>
+ <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>회의일시 <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>회의시간</Label>
+ <Input
+ placeholder="예: 14:00 ~ 16:00"
+ value={specMeetingInfo.meetingTime}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, meetingTime: e.target.value }))}
+ />
+ </div>
+ </div>
+ <div>
+ <Label>장소 <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 className="grid grid-cols-3 gap-4">
+ <div>
+ <Label>담당자 <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>연락처</Label>
+ <Input
+ placeholder="전화번호"
+ value={specMeetingInfo.contactPhone}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactPhone: e.target.value }))}
+ />
+ </div>
+ <div>
+ <Label>이메일</Label>
+ <Input
+ type="email"
+ placeholder="이메일"
+ value={specMeetingInfo.contactEmail}
+ onChange={(e) => setSpecMeetingInfo((prev) => ({ ...prev, contactEmail: e.target.value }))}
+ />
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 입찰 조건 섹션 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>입찰 조건</CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 벤더가 사전견적 시 참고할 입찰 조건을 설정하세요
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label>
+ 지급조건 <span className="text-red-500">*</span>
+ </Label>
+ <Select
+ value={biddingConditions.paymentTerms}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ paymentTerms: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="지급조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
</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 === "conditions" && (
- <span>
- 입찰 조건을 설정하세요
- {!tabValidation.conditions.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "details" && (
- <span>
- 최소 하나의 품목을 입력하세요
- {!tabValidation.details.isValid && (
- <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
- )}
- </span>
- )}
- {activeTab === "manager" && "담당자 정보를 확인하고 입찰을 생성하세요"}
- </div>
-
- <div className="flex gap-3">
- <Button
- type="button"
- variant="outline"
- onClick={() => setShowCloseConfirmDialog(true)}
- 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 ? (
- // 마지막 탭: 입찰 생성 버튼 (type="button"으로 변경)
- <Button
- type="button"
- onClick={handleCreateBidding}
- 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 className="space-y-2">
+ <Label>
+ 세금조건 <span className="text-red-500">*</span>
+ </Label>
+ <Select
+ value={biddingConditions.taxConditions}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ taxConditions: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="세금조건 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {TAX_CONDITIONS.map((condition) => (
+ <SelectItem key={condition.code} value={condition.code}>
+ {condition.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
</div>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
-
- {/* 닫기 확인 다이얼로그 */}
- <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>입찰 생성을 취소하시겠습니까?</AlertDialogTitle>
- <AlertDialogDescription>
- 현재 입력 중인 내용이 모두 삭제되며, 생성되지 않습니다.
- 정말로 취소하시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel onClick={() => handleCloseConfirm(false)}>
- 아니오 (계속 입력)
- </AlertDialogCancel>
- <AlertDialogAction onClick={() => handleCloseConfirm(true)}>
- 예 (취소)
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
-
- <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>입찰이 성공적으로 생성되었습니다</AlertDialogTitle>
- <AlertDialogDescription>
- 생성된 입찰의 상세페이지로 이동하시겠습니까?
- 아니면 현재 페이지에 남아있으시겠습니까?
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel onClick={handleStayOnPage}>
- 현재 페이지에 남기
- </AlertDialogCancel>
- <AlertDialogAction onClick={handleNavigateToDetail}>
- 상세페이지로 이동
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
+
+ <div className="space-y-2">
+ <Label>
+ 운송조건(인코텀즈) <span className="text-red-500">*</span>
+ </Label>
+ <Select
+ value={biddingConditions.incoterms}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ incoterms: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>인코텀즈 옵션 (선택사항)</Label>
+ <Input
+ placeholder="예: 현지 배송 포함, 특정 주소 배송 등"
+ value={biddingConditions.incotermsOption}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ incotermsOption: e.target.value
+ }))}
+ />
+ <p className="text-xs text-muted-foreground">
+ 인코텀즈와 관련된 추가 조건이나 특이사항을 입력하세요
+ </p>
+ </div>
+
+ <div className="space-y-2">
+ <Label>
+ 계약 납품일 <span className="text-red-500">*</span>
+ </Label>
+ <Input
+ type="date"
+ value={biddingConditions.contractDeliveryDate}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ contractDeliveryDate: e.target.value
+ }))}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label>선적지 (선택사항)</Label>
+ <Select
+ value={biddingConditions.shippingPort}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ shippingPort: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선적지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="space-y-2">
+ <Label>하역지 (선택사항)</Label>
+ <Select
+ value={biddingConditions.destinationPort}
+ onValueChange={(value) => setBiddingConditions(prev => ({
+ ...prev,
+ destinationPort: value
+ }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="하역지 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Switch
+ id="price-adjustment"
+ checked={biddingConditions.isPriceAdjustmentApplicable}
+ onCheckedChange={(checked) => setBiddingConditions(prev => ({
+ ...prev,
+ isPriceAdjustmentApplicable: checked
+ }))}
+ />
+ <Label htmlFor="price-adjustment">
+ 연동제 적용 요건 문의
+ </Label>
+ </div>
+
+ <div className="space-y-2">
+ <Label>스페어파트 옵션</Label>
+ <Textarea
+ placeholder="스페어파트 관련 옵션을 입력하세요"
+ value={biddingConditions.sparePartOptions}
+ onChange={(e) => setBiddingConditions(prev => ({
+ ...prev,
+ sparePartOptions: e.target.value
+ }))}
+ rows={3}
+ />
+ </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">
+ 최소 하나의 아이템이 필요하며, 자재그룹코드는 필수입니다
+ </p>
+ <p className="text-xs text-amber-600 mt-1">
+ 수량/단위 또는 중량/중량단위를 선택해서 입력하세요
+ </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">
+ <div className="flex items-center space-x-4 p-4 bg-muted rounded-lg">
+ <div className="text-sm font-medium">계산 기준:</div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="quantity-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'quantity'}
+ onChange={() => handleQuantityWeightModeChange('quantity')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="quantity-mode" className="text-sm">수량 기준</label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="weight-mode"
+ name="quantityWeightMode"
+ checked={quantityWeightMode === 'weight'}
+ onChange={() => handleQuantityWeightModeChange('weight')}
+ className="h-4 w-4"
+ />
+ <label htmlFor="weight-mode" className="text-sm">중량 기준</label>
+ </div>
+ </div>
+ <div className="space-y-4">
+ {prItems.length > 0 ? (
+ renderPrItemsTable()
+ ) : (
+ <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 mx-auto"
+ >
+ <Plus className="h-4 w-4" />
+ 첫 번째 아이템 추가
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="manager" className="mt-0 space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>담당자 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-2 gap-6">
+ <div className="space-y-2">
+ <Label>입찰담당자 <span className="text-red-500">*</span></Label>
+ <PurchaseGroupCodeSelector
+ onCodeSelect={(code) => {
+ form.setValue('managerName', code.DISPLAY_NAME || '')
+ }}
+ placeholder="입찰담당자 선택"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label>조달담당자 <span className="text-red-500">*</span></Label>
+ <ProcurementManagerSelector
+ onManagerSelect={(manager) => {
+ form.setValue('managerEmail', manager.DISPLAY_NAME || '')
+ }}
+ placeholder="조달담당자 선택"
+ />
+ </div>
+ </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>
+ </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>기본 정보를 입력하세요.</span>)}
+ {activeTab === 'schedule' && (<span>일정 및 사양설명회 정보를 입력하세요.</span>)}
+ {activeTab === 'details' && (<span>세부내역을 관리하세요.</span>)}
+ {activeTab === 'manager' && (<span>담당자 정보를 확인하고 입찰을 생성하세요.</span>)}
+ {!tabValidation[activeTab].isValid && (
+ <span className="text-red-500 ml-2">• 필수 항목이 누락되었습니다</span>
+ )}
+ </div>
+ <div className="flex gap-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setShowCloseConfirmDialog(true)}
+ 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 ? (
+ <Button
+ type="button"
+ onClick={handleCreateBidding}
+ disabled={isSubmitting || !isCurrentTabValid()}
+ 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 || !isCurrentTabValid()}
+ className="flex items-center gap-2"
+ >
+ 다음
+ <ChevronRight className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ {/* 닫기 확인 다이얼로그 */}
+ <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>입찰 생성을 취소하시겠습니까?</AlertDialogTitle>
+ <AlertDialogDescription>
+ 현재 입력 중인 내용이 모두 삭제되며, 생성되지 않습니다. 정말로 취소하시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={() => handleCloseConfirm(false)}>
+ 아니오 (계속 입력)
+ </AlertDialogCancel>
+ <AlertDialogAction onClick={() => handleCloseConfirm(true)}>
+ 예 (취소)
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ {/* 성공 다이얼로그 */}
+ <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>입찰이 성공적으로 생성되었습니다</AlertDialogTitle>
+ <AlertDialogDescription>
+ 생성된 입찰의 상세페이지로 이동하시겠습니까? 아니면 현재 페이지에 남아있으시겠습니까?
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel onClick={handleStayOnPage}>현재 페이지에 남기</AlertDialogCancel>
+ <AlertDialogAction onClick={handleNavigateToDetail}>상세페이지로 이동</AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </>
+ )
} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/service.ts b/lib/bidding/pre-quote/service.ts
index 0f284297..ea92f294 100644
--- a/lib/bidding/pre-quote/service.ts
+++ b/lib/bidding/pre-quote/service.ts
@@ -1,1528 +1,1619 @@
-'use server'
-
-import db from '@/db/db'
-import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
-import { basicContractTemplates } from '@/db/schema'
-import { vendors } from '@/db/schema/vendors'
-import { users } from '@/db/schema'
-import { sendEmail } from '@/lib/mail/sendEmail'
-import { eq, inArray, and, ilike, sql } from 'drizzle-orm'
-import { mkdir, writeFile } from 'fs/promises'
-import path from 'path'
-import { revalidateTag, revalidatePath } from 'next/cache'
-import { basicContract } from '@/db/schema/basicContractDocumnet'
-import { saveFile } from '@/lib/file-stroage'
-
-// userId를 user.name으로 변환하는 유틸리티 함수
-async function getUserNameById(userId: string): Promise<string> {
- try {
- const user = await db
- .select({ name: users.name })
- .from(users)
- .where(eq(users.id, parseInt(userId)))
- .limit(1)
-
- return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환
- } catch (error) {
- console.error('Failed to get user name:', error)
- return userId // 에러 시 userId를 그대로 반환
- }
-}
-
-interface CreateBiddingCompanyInput {
- biddingId: number
- companyId: number
- contactPerson?: string
- contactEmail?: string
- contactPhone?: string
- notes?: string
-}
-
-interface UpdateBiddingCompanyInput {
- contactPerson?: string
- contactEmail?: string
- contactPhone?: string
- preQuoteAmount?: number
- notes?: string
- invitationStatus?: 'pending' | 'accepted' | 'declined'
- isPreQuoteSelected?: boolean
- isAttendingMeeting?: boolean
-}
-
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-
-
-// 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
-export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
- try {
- const result = await db.transaction(async (tx) => {
- // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인
- const existingCompany = await tx
- .select()
- .from(biddingCompanies)
- .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`)
-
- if (existingCompany.length > 0) {
- throw new Error('이미 등록된 업체입니다')
- }
- // 1. biddingCompanies 레코드 생성
- const biddingCompanyResult = await tx.insert(biddingCompanies).values({
- biddingId: input.biddingId,
- companyId: input.companyId,
- invitationStatus: 'pending', // 초기 상태: 입찰생성
- invitedAt: new Date(),
- contactPerson: input.contactPerson,
- contactEmail: input.contactEmail,
- contactPhone: input.contactPhone,
- notes: input.notes,
- }).returning({ id: biddingCompanies.id })
-
- if (biddingCompanyResult.length === 0) {
- throw new Error('업체 추가에 실패했습니다.')
- }
-
- const biddingCompanyId = biddingCompanyResult[0].id
-
- // 2. company_condition_responses 레코드 생성 (기본값으로)
- await tx.insert(companyConditionResponses).values({
- biddingCompanyId: biddingCompanyId,
- // 나머지 필드들은 null로 시작 (벤더가 나중에 응답)
- })
-
- return biddingCompanyId
- })
-
- return {
- success: true,
- message: '업체가 성공적으로 추가되었습니다.',
- data: { id: result }
- }
- } catch (error) {
- console.error('Failed to create bidding company:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
- }
- }
-}
-
-// 사전견적용 업체 정보 업데이트
-export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) {
- try {
- const updateData: any = {
- updatedAt: new Date()
- }
-
- if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson
- if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail
- if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone
- if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount
- if (input.notes !== undefined) updateData.notes = input.notes
- if (input.invitationStatus !== undefined) {
- updateData.invitationStatus = input.invitationStatus
- if (input.invitationStatus !== 'pending') {
- updateData.respondedAt = new Date()
- }
- }
- if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected
- if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting
-
- await db.update(biddingCompanies)
- .set(updateData)
- .where(eq(biddingCompanies.id, id))
-
- return {
- success: true,
- message: '업체 정보가 성공적으로 업데이트되었습니다.',
- }
- } catch (error) {
- console.error('Failed to update bidding company:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.'
- }
- }
-}
-
-// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능)
-export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean) {
- try {
- // 업체들의 입찰 ID 조회 (캐시 무효화를 위해)
- const companies = await db
- .select({ biddingId: biddingCompanies.biddingId })
- .from(biddingCompanies)
- .where(inArray(biddingCompanies.id, companyIds))
- .limit(1)
-
- await db.update(biddingCompanies)
- .set({
- isPreQuoteSelected: isSelected,
- invitationStatus: 'pending', // 초기 상태: 입찰생성
- updatedAt: new Date()
- })
- .where(inArray(biddingCompanies.id, companyIds))
-
- // 캐시 무효화
- if (companies.length > 0) {
- const biddingId = companies[0].biddingId
- revalidateTag(`bidding-${biddingId}`)
- revalidateTag('bidding-detail')
- revalidateTag('quotation-vendors')
- revalidateTag('quotation-details')
- revalidatePath(`/evcp/bid/${biddingId}`)
- }
-
- const message = isSelected
- ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.`
- : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.`
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to update pre-quote selection:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.'
- }
- }
-}
-
-// 사전견적용 업체 삭제
-export async function deleteBiddingCompany(id: number) {
- try {
- // 1. 해당 업체의 초대 상태 확인
- const company = await db
- .select({ invitationStatus: biddingCompanies.invitationStatus })
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, id))
- .then(rows => rows[0])
-
- if (!company) {
- return {
- success: false,
- error: '해당 업체를 찾을 수 없습니다.'
- }
- }
-
- // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가
- if (company.invitationStatus !== 'pending') {
- return {
- success: false,
- error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.'
- }
- }
-
- await db.transaction(async (tx) => {
- // 2. 먼저 관련된 조건 응답들 삭제
- await tx.delete(companyConditionResponses)
- .where(eq(companyConditionResponses.biddingCompanyId, id))
-
- // 3. biddingCompanies 레코드 삭제
- await tx.delete(biddingCompanies)
- .where(eq(biddingCompanies.id, id))
- })
-
- return {
- success: true,
- message: '업체가 성공적으로 삭제되었습니다.'
- }
- } catch (error) {
- console.error('Failed to delete bidding company:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.'
- }
- }
-}
-
-// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인)
-export async function getBiddingCompanies(biddingId: number) {
- try {
- const companies = await db
- .select({
- // bidding_companies 필드들
- id: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- companyId: biddingCompanies.companyId,
- invitationStatus: biddingCompanies.invitationStatus,
- invitedAt: biddingCompanies.invitedAt,
- respondedAt: biddingCompanies.respondedAt,
- preQuoteAmount: biddingCompanies.preQuoteAmount,
- preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
- preQuoteDeadline: biddingCompanies.preQuoteDeadline,
- isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
- isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
- isAttendingMeeting: biddingCompanies.isAttendingMeeting,
- notes: biddingCompanies.notes,
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- contactPhone: biddingCompanies.contactPhone,
- createdAt: biddingCompanies.createdAt,
- updatedAt: biddingCompanies.updatedAt,
-
- // vendors 테이블에서 업체 정보
- companyName: vendors.vendorName,
- companyCode: vendors.vendorCode,
-
- // company_condition_responses 필드들
- paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
- taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
- proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
- priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
- isInitialResponse: companyConditionResponses.isInitialResponse,
- incotermsResponse: companyConditionResponses.incotermsResponse,
- proposedShippingPort: companyConditionResponses.proposedShippingPort,
- proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
- sparePartResponse: companyConditionResponses.sparePartResponse,
- additionalProposals: companyConditionResponses.additionalProposals,
- })
- .from(biddingCompanies)
- .leftJoin(
- vendors,
- eq(biddingCompanies.companyId, vendors.id)
- )
- .leftJoin(
- companyConditionResponses,
- eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
- )
- .where(eq(biddingCompanies.biddingId, biddingId))
-
- return {
- success: true,
- data: companies
- }
- } catch (error) {
- console.error('Failed to get bidding companies:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.'
- }
- }
-}
-
-// 선택된 업체들에게 사전견적 초대 발송
-export async function sendPreQuoteInvitations(companyIds: number[], preQuoteDeadline?: Date | string) {
- try {
- if (companyIds.length === 0) {
- return {
- success: false,
- error: '선택된 업체가 없습니다.'
- }
- }
-
- // 선택된 업체들의 정보와 입찰 정보 조회
- const companiesInfo = await db
- .select({
- biddingCompanyId: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- biddingId: biddingCompanies.biddingId,
- companyName: vendors.vendorName,
- companyEmail: vendors.email,
- // 입찰 정보
- biddingNumber: biddings.biddingNumber,
- revision: biddings.revision,
- projectName: biddings.projectName,
- biddingTitle: biddings.title,
- itemName: biddings.itemName,
- preQuoteDate: biddings.preQuoteDate,
- budget: biddings.budget,
- currency: biddings.currency,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
- .where(inArray(biddingCompanies.id, companyIds))
-
- if (companiesInfo.length === 0) {
- return {
- success: false,
- error: '업체 정보를 찾을 수 없습니다.'
- }
- }
-
- await db.transaction(async (tx) => {
- // 선택된 업체들의 상태를 '사전견적요청(초대발송)'으로 변경
- for (const id of companyIds) {
- await tx.update(biddingCompanies)
- .set({
- invitationStatus: 'sent', // 사전견적 초대 발송 상태
- invitedAt: new Date(),
- preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null,
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, id))
- }
- })
-
- // 각 업체별로 이메일 발송
- for (const company of companiesInfo) {
- if (company.companyEmail) {
- try {
- await sendEmail({
- to: company.companyEmail,
- template: 'pre-quote-invitation',
- context: {
- companyName: company.companyName,
- biddingNumber: company.biddingNumber,
- revision: company.revision,
- projectName: company.projectName,
- biddingTitle: company.biddingTitle,
- itemName: company.itemName,
- preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : null,
- budget: company.budget ? company.budget.toLocaleString() : null,
- currency: company.currency,
- managerName: company.managerName,
- managerEmail: company.managerEmail,
- managerPhone: company.managerPhone,
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`,
- currentYear: new Date().getFullYear(),
- language: 'ko'
- }
- })
- } catch (emailError) {
- console.error(`Failed to send email to ${company.companyEmail}:`, emailError)
- // 이메일 발송 실패해도 전체 프로세스는 계속 진행
- }
- }
- }
- // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만)
- for (const company of companiesInfo) {
- await db.transaction(async (tx) => {
- await tx
- .update(biddings)
- .set({
- status: 'request_for_quotation',
- updatedAt: new Date()
- })
- .where(and(
- eq(biddings.id, company.biddingId),
- eq(biddings.status, 'bidding_generated')
- ))
- })
- }
- return {
- success: true,
- message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.`
- }
- } catch (error) {
- console.error('Failed to send pre-quote invitations:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.'
- }
- }
-}
-
-// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계)
-export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) {
- try {
- // 1. 먼저 입찰 기본 정보를 가져옴
- const biddingResult = await db
- .select({
- id: biddings.id,
- biddingNumber: biddings.biddingNumber,
- revision: biddings.revision,
- projectName: biddings.projectName,
- itemName: biddings.itemName,
- title: biddings.title,
- description: biddings.description,
- content: biddings.content,
- contractType: biddings.contractType,
- biddingType: biddings.biddingType,
- awardCount: biddings.awardCount,
- contractStartDate: biddings.contractStartDate,
- contractEndDate: biddings.contractEndDate,
- preQuoteDate: biddings.preQuoteDate,
- biddingRegistrationDate: biddings.biddingRegistrationDate,
- submissionStartDate: biddings.submissionStartDate,
- submissionEndDate: biddings.submissionEndDate,
- evaluationDate: biddings.evaluationDate,
- currency: biddings.currency,
- budget: biddings.budget,
- targetPrice: biddings.targetPrice,
- status: biddings.status,
- managerName: biddings.managerName,
- managerEmail: biddings.managerEmail,
- managerPhone: biddings.managerPhone,
- })
- .from(biddings)
- .where(eq(biddings.id, biddingId))
- .limit(1)
-
- if (biddingResult.length === 0) {
- return null
- }
-
- const biddingData = biddingResult[0]
-
- // 2. 해당 업체의 biddingCompanies 정보 조회
- const companyResult = await db
- .select({
- biddingCompanyId: biddingCompanies.id,
- biddingId: biddingCompanies.biddingId,
- invitationStatus: biddingCompanies.invitationStatus,
- preQuoteAmount: biddingCompanies.preQuoteAmount,
- preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
- preQuoteDeadline: biddingCompanies.preQuoteDeadline,
- isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
- isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
- isAttendingMeeting: biddingCompanies.isAttendingMeeting,
- // company_condition_responses 정보
- paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
- taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
- incotermsResponse: companyConditionResponses.incotermsResponse,
- proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
- proposedShippingPort: companyConditionResponses.proposedShippingPort,
- proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
- priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
- sparePartResponse: companyConditionResponses.sparePartResponse,
- isInitialResponse: companyConditionResponses.isInitialResponse,
- additionalProposals: companyConditionResponses.additionalProposals,
- })
- .from(biddingCompanies)
- .leftJoin(
- companyConditionResponses,
- eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
- )
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.companyId, companyId)
- )
- )
- .limit(1)
-
- // 3. 결과 조합
- if (companyResult.length === 0) {
- // 아직 초대되지 않은 상태
- return {
- ...biddingData,
- biddingCompanyId: null,
- biddingId: biddingData.id,
- invitationStatus: null,
- preQuoteAmount: null,
- preQuoteSubmittedAt: null,
- preQuoteDeadline: null,
- isPreQuoteSelected: false,
- isPreQuoteParticipated: null,
- isAttendingMeeting: null,
- paymentTermsResponse: null,
- taxConditionsResponse: null,
- incotermsResponse: null,
- proposedContractDeliveryDate: null,
- proposedShippingPort: null,
- proposedDestinationPort: null,
- priceAdjustmentResponse: null,
- sparePartResponse: null,
- isInitialResponse: null,
- additionalProposals: null,
- }
- }
-
- const companyData = companyResult[0]
-
- return {
- ...biddingData,
- ...companyData,
- biddingId: biddingData.id, // bidding ID 보장
- }
- } catch (error) {
- console.error('Failed to get bidding companies for partners:', error)
- throw error
- }
-}
-
-// Partners에서 사전견적 응답 제출
-export async function submitPreQuoteResponse(
- biddingCompanyId: number,
- responseData: {
- preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional
- prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가
- paymentTermsResponse?: string
- taxConditionsResponse?: string
- incotermsResponse?: string
- proposedContractDeliveryDate?: string
- proposedShippingPort?: string
- proposedDestinationPort?: string
- priceAdjustmentResponse?: boolean
- isInitialResponse?: boolean
- sparePartResponse?: string
- additionalProposals?: string
- priceAdjustmentForm?: any
- },
- userId: string
-) {
- try {
- let finalAmount = responseData.preQuoteAmount || 0
-
- await db.transaction(async (tx) => {
- // 1. 품목별 견적 정보 최종 저장 (사전견적 제출)
- if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
- // 기존 사전견적 품목 삭제 후 새로 생성
- await tx.delete(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // 품목별 견적 최종 저장
- for (const item of responseData.prItemQuotations) {
- await tx.insert(companyPrItemBids)
- .values({
- biddingCompanyId,
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice.toString(),
- bidAmount: item.bidAmount.toString(),
- proposedDeliveryDate: item.proposedDeliveryDate || null,
- technicalSpecification: item.technicalSpecification || null,
- currency: 'KRW',
- isPreQuote: true,
- submittedAt: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- })
- }
-
- // 총 금액 다시 계산
- finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
- }
-
- // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경)
- await tx.update(biddingCompanies)
- .set({
- preQuoteAmount: finalAmount.toString(),
- preQuoteSubmittedAt: new Date(),
- invitationStatus: 'submitted', // 사전견적 제출 완료 상태로 변경
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- // 3. company_condition_responses 업데이트
- const finalConditionResult = await tx.update(companyConditionResponses)
- .set({
- paymentTermsResponse: responseData.paymentTermsResponse,
- taxConditionsResponse: responseData.taxConditionsResponse,
- incotermsResponse: responseData.incotermsResponse,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
- proposedShippingPort: responseData.proposedShippingPort,
- proposedDestinationPort: responseData.proposedDestinationPort,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse,
- isInitialResponse: responseData.isInitialResponse,
- sparePartResponse: responseData.sparePartResponse,
- additionalProposals: responseData.additionalProposals,
- updatedAt: new Date()
- })
- .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
- .returning()
-
- // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
- if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) {
- const companyConditionResponseId = finalConditionResult[0].id
-
- const priceAdjustmentData = {
- companyConditionResponsesId: companyConditionResponseId,
- itemName: responseData.priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
- comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
- adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
- notes: responseData.priceAdjustmentForm.notes,
- adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
- adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
- nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
- } as any
-
- // 기존 연동제 정보가 있는지 확인
- const existingPriceAdjustment = await tx
- .select()
- .from(priceAdjustmentForms)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- .limit(1)
-
- if (existingPriceAdjustment.length > 0) {
- // 업데이트
- await tx
- .update(priceAdjustmentForms)
- .set(priceAdjustmentData)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- } else {
- // 새로 생성
- await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
- }
- }
-
- // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만)
- // 또한 사전견적 접수일 업데이트
- const biddingCompany = await tx
- .select({ biddingId: biddingCompanies.biddingId })
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, biddingCompanyId))
- .limit(1)
-
- if (biddingCompany.length > 0) {
- await tx
- .update(biddings)
- .set({
- status: 'received_quotation',
- preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트
- updatedAt: new Date()
- })
- .where(and(
- eq(biddings.id, biddingCompany[0].biddingId),
- eq(biddings.status, 'request_for_quotation')
- ))
- }
- })
-
- return {
- success: true,
- message: '사전견적이 성공적으로 제출되었습니다.'
- }
- } catch (error) {
- console.error('Failed to submit pre-quote response:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.'
- }
- }
-}
-
-// Partners에서 사전견적 참여 의사 결정 (수락/거절)
-export async function respondToPreQuoteInvitation(
- biddingCompanyId: number,
- response: 'accepted' | 'declined'
-) {
- try {
- await db.update(biddingCompanies)
- .set({
- invitationStatus: response, // accepted 또는 declined
- respondedAt: new Date(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- const message = response === 'accepted' ?
- '사전견적 참여를 수락했습니다.' :
- '사전견적 참여를 거절했습니다.'
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to respond to pre-quote invitation:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.'
- }
- }
-}
-
-// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용)
-export async function setPreQuoteParticipation(
- biddingCompanyId: number,
- isParticipating: boolean
-) {
- try {
- await db.update(biddingCompanies)
- .set({
- isPreQuoteParticipated: isParticipating,
- respondedAt: new Date(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
-
- const message = isParticipating ?
- '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' :
- '사전견적 참여를 거절했습니다.'
-
- return {
- success: true,
- message
- }
- } catch (error) {
- console.error('Failed to set pre-quote participation:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.'
- }
- }
-}
-
-// PR 아이템 조회 (입찰에 포함된 품목들)
-export async function getPrItemsForBidding(biddingId: number) {
- try {
- const prItems = await db
- .select({
- id: prItemsForBidding.id,
- itemNumber: prItemsForBidding.itemNumber,
- prNumber: prItemsForBidding.prNumber,
- itemInfo: prItemsForBidding.itemInfo,
- materialDescription: prItemsForBidding.materialDescription,
- quantity: prItemsForBidding.quantity,
- quantityUnit: prItemsForBidding.quantityUnit,
- totalWeight: prItemsForBidding.totalWeight,
- weightUnit: prItemsForBidding.weightUnit,
- currency: prItemsForBidding.currency,
- requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
- hasSpecDocument: prItemsForBidding.hasSpecDocument
- })
- .from(prItemsForBidding)
- .where(eq(prItemsForBidding.biddingId, biddingId))
-
- return prItems
- } catch (error) {
- console.error('Failed to get PR items for bidding:', error)
- return []
- }
-}
-
-// SPEC 문서 조회 (PR 아이템에 연결된 문서들)
-export async function getSpecDocumentsForPrItem(prItemId: number) {
- try {
-
- const specDocs = await db
- .select({
- id: biddingDocuments.id,
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- fileSize: biddingDocuments.fileSize,
- filePath: biddingDocuments.filePath,
- title: biddingDocuments.title,
- description: biddingDocuments.description,
- uploadedAt: biddingDocuments.uploadedAt
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.prItemId, prItemId),
- eq(biddingDocuments.documentType, 'spec_document')
- )
- )
-
- return specDocs
- } catch (error) {
- console.error('Failed to get spec documents for PR item:', error)
- return []
- }
-}
-
-// 사전견적 임시저장
-export async function savePreQuoteDraft(
- biddingCompanyId: number,
- responseData: {
- prItemQuotations?: PrItemQuotation[]
- paymentTermsResponse?: string
- taxConditionsResponse?: string
- incotermsResponse?: string
- proposedContractDeliveryDate?: string
- proposedShippingPort?: string
- proposedDestinationPort?: string
- priceAdjustmentResponse?: boolean
- isInitialResponse?: boolean
- sparePartResponse?: string
- additionalProposals?: string
- priceAdjustmentForm?: any
- },
- userId: string
-) {
- try {
- let totalAmount = 0
-
- await db.transaction(async (tx) => {
- // 품목별 견적 정보 저장
- if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
- // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기)
- await tx.delete(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // 새로운 품목별 견적 저장
- for (const item of responseData.prItemQuotations) {
- await tx.insert(companyPrItemBids)
- .values({
- biddingCompanyId,
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice.toString(),
- bidAmount: item.bidAmount.toString(),
- proposedDeliveryDate: item.proposedDeliveryDate || null,
- technicalSpecification: item.technicalSpecification || null,
- currency: 'KRW',
- isPreQuote: true, // 사전견적 표시
- submittedAt: new Date(),
- createdAt: new Date(),
- updatedAt: new Date()
- })
- }
-
- // 총 금액 계산
- totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
-
- // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음)
- await tx.update(biddingCompanies)
- .set({
- preQuoteAmount: totalAmount.toString(),
- updatedAt: new Date()
- })
- .where(eq(biddingCompanies.id, biddingCompanyId))
- }
-
- // company_condition_responses 업데이트 (임시저장)
- const conditionResult = await tx.update(companyConditionResponses)
- .set({
- paymentTermsResponse: responseData.paymentTermsResponse || null,
- taxConditionsResponse: responseData.taxConditionsResponse || null,
- incotermsResponse: responseData.incotermsResponse || null,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null,
- proposedShippingPort: responseData.proposedShippingPort || null,
- proposedDestinationPort: responseData.proposedDestinationPort || null,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse || null,
- isInitialResponse: responseData.isInitialResponse || null,
- sparePartResponse: responseData.sparePartResponse || null,
- additionalProposals: responseData.additionalProposals || null,
- updatedAt: new Date()
- })
- .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
- .returning()
-
- // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
- if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) {
- const companyConditionResponseId = conditionResult[0].id
-
- const priceAdjustmentData = {
- companyConditionResponsesId: companyConditionResponseId,
- itemName: responseData.priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
- comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
- adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
- notes: responseData.priceAdjustmentForm.notes,
- adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
- adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
- nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
- } as any
-
- // 기존 연동제 정보가 있는지 확인
- const existingPriceAdjustment = await tx
- .select()
- .from(priceAdjustmentForms)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- .limit(1)
-
- if (existingPriceAdjustment.length > 0) {
- // 업데이트
- await tx
- .update(priceAdjustmentForms)
- .set(priceAdjustmentData)
- .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
- } else {
- // 새로 생성
- await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
- }
- }
- })
-
- return {
- success: true,
- message: '임시저장이 완료되었습니다.',
- totalAmount
- }
- } catch (error) {
- console.error('Failed to save pre-quote draft:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '임시저장에 실패했습니다.'
- }
- }
-}
-
-// 견적 문서 업로드
-export async function uploadPreQuoteDocument(
- biddingId: number,
- companyId: number,
- file: File,
- userId: string
-) {
- try {
- const userName = await getUserNameById(userId)
- // 파일 저장
- const saveResult = await saveFile({
- file,
- directory: `bidding/${biddingId}/quotations`,
- originalName: file.name,
- userId
- })
-
- if (!saveResult.success) {
- return {
- success: false,
- error: saveResult.error || '파일 저장에 실패했습니다.'
- }
- }
-
- // 데이터베이스에 문서 정보 저장
- const result = await db.insert(biddingDocuments)
- .values({
- biddingId,
- companyId,
- documentType: 'other', // 견적서 타입
- fileName: saveResult.fileName!,
- originalFileName: file.name,
- fileSize: file.size,
- mimeType: file.type,
- filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로)
- title: `견적서 - ${file.name}`,
- description: '협력업체 제출 견적서',
- isPublic: false,
- isRequired: false,
- uploadedBy: userName,
- uploadedAt: new Date()
- })
- .returning()
-
- return {
- success: true,
- message: '견적서가 성공적으로 업로드되었습니다.',
- documentId: result[0].id
- }
- } catch (error) {
- console.error('Failed to upload pre-quote document:', error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.'
- }
- }
-}
-
-// 업로드된 견적 문서 목록 조회
-export async function getPreQuoteDocuments(biddingId: number, companyId: number) {
- try {
- const documents = await db
- .select({
- id: biddingDocuments.id,
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- fileSize: biddingDocuments.fileSize,
- filePath: biddingDocuments.filePath,
- title: biddingDocuments.title,
- description: biddingDocuments.description,
- uploadedAt: biddingDocuments.uploadedAt,
- uploadedBy: biddingDocuments.uploadedBy
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.biddingId, biddingId),
- eq(biddingDocuments.companyId, companyId),
- )
- )
-
- return documents
- } catch (error) {
- console.error('Failed to get pre-quote documents:', error)
- return []
- }
- }
-
-// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용)
-export async function getSavedPrItemQuotations(biddingCompanyId: number) {
- try {
- const savedQuotations = await db
- .select({
- prItemId: companyPrItemBids.prItemId,
- bidUnitPrice: companyPrItemBids.bidUnitPrice,
- bidAmount: companyPrItemBids.bidAmount,
- proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
- technicalSpecification: companyPrItemBids.technicalSpecification,
- currency: companyPrItemBids.currency
- })
- .from(companyPrItemBids)
- .where(
- and(
- eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
- eq(companyPrItemBids.isPreQuote, true)
- )
- )
-
- // Decimal 타입을 number로 변환
- return savedQuotations.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: parseFloat(item.bidUnitPrice || '0'),
- bidAmount: parseFloat(item.bidAmount || '0'),
- proposedDeliveryDate: item.proposedDeliveryDate,
- technicalSpecification: item.technicalSpecification,
- currency: item.currency
- }))
- } catch (error) {
- console.error('Failed to get saved PR item quotations:', error)
- return []
- }
- }
-
-// 견적 문서 정보 조회 (다운로드용)
-export async function getPreQuoteDocumentForDownload(
- documentId: number,
- biddingId: number,
- companyId: number
-) {
- try {
- const document = await db
- .select({
- fileName: biddingDocuments.fileName,
- originalFileName: biddingDocuments.originalFileName,
- filePath: biddingDocuments.filePath
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.id, documentId),
- eq(biddingDocuments.biddingId, biddingId),
- eq(biddingDocuments.companyId, companyId),
- eq(biddingDocuments.documentType, 'other')
- )
- )
- .limit(1)
-
- if (document.length === 0) {
- return {
- success: false,
- error: '문서를 찾을 수 없습니다.'
- }
- }
-
- return {
- success: true,
- document: document[0]
- }
- } catch (error) {
- console.error('Failed to get pre-quote document:', error)
- return {
- success: false,
- error: '문서 정보 조회에 실패했습니다.'
- }
- }
-}
-
-// 견적 문서 삭제
-export async function deletePreQuoteDocument(
- documentId: number,
- biddingId: number,
- companyId: number,
- userId: string
-) {
- try {
- // 문서 존재 여부 및 권한 확인
- const document = await db
- .select({
- id: biddingDocuments.id,
- fileName: biddingDocuments.fileName,
- filePath: biddingDocuments.filePath,
- uploadedBy: biddingDocuments.uploadedBy
- })
- .from(biddingDocuments)
- .where(
- and(
- eq(biddingDocuments.id, documentId),
- eq(biddingDocuments.biddingId, biddingId),
- eq(biddingDocuments.companyId, companyId),
- eq(biddingDocuments.documentType, 'other')
- )
- )
- .limit(1)
-
- if (document.length === 0) {
- return {
- success: false,
- error: '문서를 찾을 수 없습니다.'
- }
- }
-
- const doc = document[0]
-
- // 데이터베이스에서 문서 정보 삭제
- await db
- .delete(biddingDocuments)
- .where(eq(biddingDocuments.id, documentId))
-
- return {
- success: true,
- message: '문서가 성공적으로 삭제되었습니다.'
- }
- } catch (error) {
- console.error('Failed to delete pre-quote document:', error)
- return {
- success: false,
- error: '문서 삭제에 실패했습니다.'
- }
- }
- }
-
-// 기본계약 발송 (서버 액션)
-export async function sendBiddingBasicContracts(
- biddingId: number,
- vendorData: Array<{
- vendorId: number
- vendorName: string
- vendorCode?: string
- vendorCountry?: string
- selectedMainEmail: string
- additionalEmails: string[]
- customEmails?: Array<{ email: string; name?: string }>
- contractRequirements: {
- ndaYn: boolean
- generalGtcYn: boolean
- projectGtcYn: boolean
- agreementYn: boolean
- }
- biddingCompanyId: number
- biddingId: number
- hasExistingContracts?: boolean
- }>,
- generatedPdfs: Array<{
- key: string
- buffer: number[]
- fileName: string
- }>,
- message?: string
-) {
- try {
- console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) });
-
- // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용)
- const [currentUser] = await db.select().from(users).limit(1)
-
- if (!currentUser) {
- throw new Error("사용자 정보를 찾을 수 없습니다.")
- }
-
- const results = []
- const savedContracts = []
-
- // 트랜잭션 시작
- const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
- await mkdir(contractsDir, { recursive: true });
-
- const result = await db.transaction(async (tx) => {
- // 각 벤더별로 기본계약 생성 및 이메일 발송
- for (const vendor of vendorData) {
- // 기존 계약 확인 (biddingCompanyId 기준)
- if (vendor.hasExistingContracts) {
- console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`)
- continue
- }
-
- // 벤더 정보 조회
- const [vendorInfo] = await tx
- .select()
- .from(vendors)
- .where(eq(vendors.id, vendor.vendorId))
- .limit(1)
-
- if (!vendorInfo) {
- console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`)
- continue
- }
-
- // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용)
- console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`)
- let [biddingCompanyInfo] = await tx
- .select()
- .from(biddingCompanies)
- .where(eq(biddingCompanies.id, vendor.biddingCompanyId))
- .limit(1)
-
- console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo)
- if (!biddingCompanyInfo) {
- console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`)
- // fallback: biddingId와 vendorId로 찾기 시도
- console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`)
- const [fallbackCompanyInfo] = await tx
- .select()
- .from(biddingCompanies)
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.companyId, vendor.vendorId)
- ))
- .limit(1)
- console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo)
- if (fallbackCompanyInfo) {
- console.log(`Using fallback biddingCompanyInfo`)
- biddingCompanyInfo = fallbackCompanyInfo
- } else {
- console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10))
- continue
- }
- }
-
- // 계약 요구사항에 따라 계약서 생성
- const contractTypes: Array<{ type: string; templateName: string }> = []
- if (vendor.contractRequirements.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' })
- if (vendor.contractRequirements.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' })
- if (vendor.contractRequirements.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' })
- if (vendor.contractRequirements.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' })
- console.log("contractTypes", contractTypes)
- for (const contractType of contractTypes) {
- // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기)
- console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key))
- const pdfData = generatedPdfs.find((pdf: any) =>
- pdf.key.includes(`${vendor.vendorId}_`) &&
- pdf.key.includes(`_${contractType.templateName}`)
- )
- console.log("pdfData", pdfData, "for contractType", contractType)
- if (!pdfData) {
- console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`)
- continue
- }
-
- // 파일 저장 (rfq-last 방식)
- const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
- const filePath = path.join(contractsDir, fileName);
-
- await writeFile(filePath, Buffer.from(pdfData.buffer));
-
- // 템플릿 정보 조회 (rfq-last 방식)
- const [template] = await db
- .select()
- .from(basicContractTemplates)
- .where(
- and(
- ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`),
- eq(basicContractTemplates.status, "ACTIVE")
- )
- )
- .limit(1);
-
- console.log("템플릿", contractType.templateName, template);
-
- // 기존 계약이 있는지 확인 (rfq-last 방식)
- const [existingContract] = await tx
- .select()
- .from(basicContract)
- .where(
- and(
- eq(basicContract.templateId, template?.id),
- eq(basicContract.vendorId, vendor.vendorId),
- eq(basicContract.biddingCompanyId, biddingCompanyInfo.id)
- )
- )
- .limit(1);
-
- let contractRecord;
-
- if (existingContract) {
- // 기존 계약이 있으면 업데이트
- [contractRecord] = await tx
- .update(basicContract)
- .set({
- requestedBy: currentUser.id,
- status: "PENDING", // 재발송 상태
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
- updatedAt: new Date(),
- })
- .where(eq(basicContract.id, existingContract.id))
- .returning();
-
- console.log("기존 계약 업데이트:", contractRecord.id);
- } else {
- // 새 계약 생성
- [contractRecord] = await tx
- .insert(basicContract)
- .values({
- templateId: template?.id || null,
- vendorId: vendor.vendorId,
- biddingCompanyId: biddingCompanyInfo.id,
- rfqCompanyId: null,
- generalContractId: null,
- requestedBy: currentUser.id,
- status: 'PENDING',
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
- createdAt: new Date(),
- updatedAt: new Date(),
- })
- .returning();
-
- console.log("새 계약 생성:", contractRecord.id);
- }
-
- results.push({
- vendorId: vendor.vendorId,
- vendorName: vendor.vendorName,
- contractId: contractRecord.id,
- contractType: contractType.type,
- fileName: fileName,
- filePath: `/contracts/generated/${fileName}`,
- })
-
- // savedContracts에 추가 (rfq-last 방식)
- // savedContracts.push({
- // vendorId: vendor.vendorId,
- // vendorName: vendor.vendorName,
- // templateName: contractType.templateName,
- // contractId: contractRecord.id,
- // fileName: fileName,
- // isUpdated: !!existingContract, // 업데이트 여부 표시
- // })
- }
-
- // 이메일 발송 (선택사항)
- if (vendor.selectedMainEmail) {
- try {
- await sendEmail({
- to: vendor.selectedMainEmail,
- template: 'basic-contract-notification',
- context: {
- vendorName: vendor.vendorName,
- biddingId: biddingId,
- contractCount: contractTypes.length,
- deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'),
- loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`,
- message: message || '',
- currentYear: new Date().getFullYear(),
- language: 'ko'
- }
- })
- } catch (emailError) {
- console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError)
- // 이메일 발송 실패해도 계약 생성은 유지
- }
- }
- }
-
- return {
- success: true,
- message: `${results.length}개의 기본계약이 생성되었습니다.`,
- results,
- savedContracts,
- totalContracts: savedContracts.length,
- }
- })
-
- return result
-
- } catch (error) {
- console.error('기본계약 발송 실패:', error)
- throw new Error(
- error instanceof Error
- ? error.message
- : '기본계약 발송 중 오류가 발생했습니다.'
- )
- }
-}
-
-// 기존 기본계약 조회 (서버 액션)
-export async function getExistingBasicContractsForBidding(biddingId: number) {
- try {
- // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회
- const existingContracts = await db
- .select({
- id: basicContract.id,
- vendorId: basicContract.vendorId,
- biddingCompanyId: basicContract.biddingCompanyId,
- biddingId: biddingCompanies.biddingId,
- templateId: basicContract.templateId,
- status: basicContract.status,
- createdAt: basicContract.createdAt,
- })
- .from(basicContract)
- .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id))
- .where(
- and(
- eq(biddingCompanies.biddingId, biddingId),
- )
- )
-
- return {
- success: true,
- contracts: existingContracts
- }
-
- } catch (error) {
- console.error('기존 계약 조회 실패:', error)
- return {
- success: false,
- error: '기존 계약 조회에 실패했습니다.'
- }
- }
-}
-
-// 선정된 업체들 조회 (서버 액션)
-export async function getSelectedVendorsForBidding(biddingId: number) {
- try {
- const selectedCompanies = await db
- .select({
- id: biddingCompanies.id,
- companyId: biddingCompanies.companyId,
- companyName: vendors.vendorName,
- companyCode: vendors.vendorCode,
- companyCountry: vendors.country,
- contactPerson: biddingCompanies.contactPerson,
- contactEmail: biddingCompanies.contactEmail,
- biddingId: biddingCompanies.biddingId,
- })
- .from(biddingCompanies)
- .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
- .where(and(
- eq(biddingCompanies.biddingId, biddingId),
- eq(biddingCompanies.isPreQuoteSelected, true)
- ))
-
- return {
- success: true,
- vendors: selectedCompanies.map(company => ({
- vendorId: company.companyId, // 실제 vendor ID
- vendorName: company.companyName || '',
- vendorCode: company.companyCode,
- vendorCountry: company.companyCountry || '대한민국',
- contactPerson: company.contactPerson,
- contactEmail: company.contactEmail,
- biddingCompanyId: company.id, // biddingCompany ID
- biddingId: company.biddingId,
- ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
- generalGtcYn: true,
- projectGtcYn: true,
- agreementYn: true
- }))
- }
- } catch (error) {
- console.error('선정된 업체 조회 실패:', error)
- return {
- success: false,
- error: '선정된 업체 조회에 실패했습니다.',
- vendors: []
- }
- }
+'use server'
+
+import db from '@/db/db'
+import { biddingCompanies, companyConditionResponses, biddings, prItemsForBidding, biddingDocuments, companyPrItemBids, priceAdjustmentForms } from '@/db/schema/bidding'
+import { basicContractTemplates } from '@/db/schema'
+import { vendors } from '@/db/schema/vendors'
+import { users } from '@/db/schema'
+import { sendEmail } from '@/lib/mail/sendEmail'
+import { eq, inArray, and, ilike, sql } from 'drizzle-orm'
+import { mkdir, writeFile } from 'fs/promises'
+import path from 'path'
+import { revalidateTag, revalidatePath } from 'next/cache'
+import { basicContract } from '@/db/schema/basicContractDocumnet'
+import { saveFile } from '@/lib/file-stroage'
+
+// userId를 user.name으로 변환하는 유틸리티 함수
+async function getUserNameById(userId: string): Promise<string> {
+ try {
+ const user = await db
+ .select({ name: users.name })
+ .from(users)
+ .where(eq(users.id, parseInt(userId)))
+ .limit(1)
+
+ return user[0]?.name || userId // user.name이 없으면 userId를 그대로 반환
+ } catch (error) {
+ console.error('Failed to get user name:', error)
+ return userId // 에러 시 userId를 그대로 반환
+ }
+}
+
+interface CreateBiddingCompanyInput {
+ biddingId: number
+ companyId: number
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ notes?: string
+}
+
+interface UpdateBiddingCompanyInput {
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ preQuoteAmount?: number
+ notes?: string
+ invitationStatus?: 'pending' | 'pre_quote_sent' | 'pre_quote_accepted' | 'pre_quote_declined' | 'pre_quote_submitted' | 'bidding_sent' | 'bidding_accepted' | 'bidding_declined' | 'bidding_cancelled' | 'bidding_submitted'
+ isPreQuoteSelected?: boolean
+ isAttendingMeeting?: boolean
+}
+
+interface PrItemQuotation {
+ prItemId: number
+ bidUnitPrice: number
+ bidAmount: number
+ proposedDeliveryDate?: string
+ technicalSpecification?: string
+}
+
+
+
+ // 사전견적용 업체 추가 - biddingCompanies와 company_condition_responses 레코드 생성
+export async function createBiddingCompany(input: CreateBiddingCompanyInput) {
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 0. 중복 체크 - 이미 해당 입찰에 참여중인 업체인지 확인
+ const existingCompany = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(sql`${biddingCompanies.biddingId} = ${input.biddingId} AND ${biddingCompanies.companyId} = ${input.companyId}`)
+
+ if (existingCompany.length > 0) {
+ throw new Error('이미 등록된 업체입니다')
+ }
+ // 1. biddingCompanies 레코드 생성
+ const biddingCompanyResult = await tx.insert(biddingCompanies).values({
+ biddingId: input.biddingId,
+ companyId: input.companyId,
+ invitationStatus: 'pending', // 초기 상태: 초대 대기
+ invitedAt: new Date(),
+ contactPerson: input.contactPerson,
+ contactEmail: input.contactEmail,
+ contactPhone: input.contactPhone,
+ notes: input.notes,
+ }).returning({ id: biddingCompanies.id })
+
+ if (biddingCompanyResult.length === 0) {
+ throw new Error('업체 추가에 실패했습니다.')
+ }
+
+ const biddingCompanyId = biddingCompanyResult[0].id
+
+ // 2. company_condition_responses 레코드 생성 (기본값으로)
+ await tx.insert(companyConditionResponses).values({
+ biddingCompanyId: biddingCompanyId,
+ // 나머지 필드들은 null로 시작 (벤더가 나중에 응답)
+ })
+
+ return biddingCompanyId
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 추가되었습니다.',
+ data: { id: result }
+ }
+ } catch (error) {
+ console.error('Failed to create bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 추가에 실패했습니다.'
+ }
+ }
+}
+
+// 사전견적용 업체 정보 업데이트
+export async function updateBiddingCompany(id: number, input: UpdateBiddingCompanyInput) {
+ try {
+ const updateData: any = {
+ updatedAt: new Date()
+ }
+
+ if (input.contactPerson !== undefined) updateData.contactPerson = input.contactPerson
+ if (input.contactEmail !== undefined) updateData.contactEmail = input.contactEmail
+ if (input.contactPhone !== undefined) updateData.contactPhone = input.contactPhone
+ if (input.preQuoteAmount !== undefined) updateData.preQuoteAmount = input.preQuoteAmount
+ if (input.notes !== undefined) updateData.notes = input.notes
+ if (input.invitationStatus !== undefined) {
+ updateData.invitationStatus = input.invitationStatus
+ if (input.invitationStatus !== 'pending') {
+ updateData.respondedAt = new Date()
+ }
+ }
+ if (input.isPreQuoteSelected !== undefined) updateData.isPreQuoteSelected = input.isPreQuoteSelected
+ if (input.isAttendingMeeting !== undefined) updateData.isAttendingMeeting = input.isAttendingMeeting
+
+ await db.update(biddingCompanies)
+ .set(updateData)
+ .where(eq(biddingCompanies.id, id))
+
+ return {
+ success: true,
+ message: '업체 정보가 성공적으로 업데이트되었습니다.',
+ }
+ } catch (error) {
+ console.error('Failed to update bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 본입찰 등록 상태 업데이트 (복수 업체 선택 가능)
+export async function updatePreQuoteSelection(companyIds: number[], isSelected: boolean) {
+ try {
+ // 업체들의 입찰 ID 조회 (캐시 무효화를 위해)
+ const companies = await db
+ .select({ biddingId: biddingCompanies.biddingId })
+ .from(biddingCompanies)
+ .where(inArray(biddingCompanies.id, companyIds))
+ .limit(1)
+
+ await db.update(biddingCompanies)
+ .set({
+ isPreQuoteSelected: isSelected,
+ invitationStatus: 'pending', // 초기 상태: 초대 대기
+ updatedAt: new Date()
+ })
+ .where(inArray(biddingCompanies.id, companyIds))
+
+ // 캐시 무효화
+ if (companies.length > 0) {
+ const biddingId = companies[0].biddingId
+ revalidateTag(`bidding-${biddingId}`)
+ revalidateTag('bidding-detail')
+ revalidateTag('quotation-vendors')
+ revalidateTag('quotation-details')
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ }
+
+ const message = isSelected
+ ? `${companyIds.length}개 업체가 본입찰 대상으로 선정되었습니다.`
+ : `${companyIds.length}개 업체의 본입찰 선정이 취소되었습니다.`
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to update pre-quote selection:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '본입찰 선정 상태 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 사전견적용 업체 삭제
+export async function deleteBiddingCompany(id: number) {
+ try {
+ // 1. 해당 업체의 초대 상태 확인
+ const company = await db
+ .select({ invitationStatus: biddingCompanies.invitationStatus })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, id))
+ .then(rows => rows[0])
+
+ if (!company) {
+ return {
+ success: false,
+ error: '해당 업체를 찾을 수 없습니다.'
+ }
+ }
+
+ // 이미 초대가 발송된 경우(수락, 거절, 요청됨 등) 삭제 불가
+ if (company.invitationStatus !== 'pending') {
+ return {
+ success: false,
+ error: '이미 초대를 보낸 업체는 삭제할 수 없습니다.'
+ }
+ }
+
+ await db.transaction(async (tx) => {
+ // 2. 먼저 관련된 조건 응답들 삭제
+ await tx.delete(companyConditionResponses)
+ .where(eq(companyConditionResponses.biddingCompanyId, id))
+
+ // 3. biddingCompanies 레코드 삭제
+ await tx.delete(biddingCompanies)
+ .where(eq(biddingCompanies.id, id))
+ })
+
+ return {
+ success: true,
+ message: '업체가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete bidding company:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// 특정 입찰의 참여 업체 목록 조회 (company_condition_responses와 vendors 조인)
+export async function getBiddingCompanies(biddingId: number) {
+ try {
+ const companies = await db
+ .select({
+ // bidding_companies 필드들
+ id: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ companyId: biddingCompanies.companyId,
+ invitationStatus: biddingCompanies.invitationStatus,
+ invitedAt: biddingCompanies.invitedAt,
+ respondedAt: biddingCompanies.respondedAt,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ preQuoteDeadline: biddingCompanies.preQuoteDeadline,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
+ notes: biddingCompanies.notes,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ contactPhone: biddingCompanies.contactPhone,
+ createdAt: biddingCompanies.createdAt,
+ updatedAt: biddingCompanies.updatedAt,
+
+ // vendors 테이블에서 업체 정보
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+ companyEmail: vendors.email, // 벤더의 기본 이메일
+
+ // company_condition_responses 필드들
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
+ additionalProposals: companyConditionResponses.additionalProposals,
+ })
+ .from(biddingCompanies)
+ .leftJoin(
+ vendors,
+ eq(biddingCompanies.companyId, vendors.id)
+ )
+ .leftJoin(
+ companyConditionResponses,
+ eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
+ )
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ // 디버깅: 서버에서 가져온 데이터 확인
+ console.log('=== getBiddingCompanies Server Log ===')
+ console.log('Total companies:', companies.length)
+ if (companies.length > 0) {
+ console.log('First company:', {
+ companyName: companies[0].companyName,
+ companyEmail: companies[0].companyEmail,
+ companyCode: companies[0].companyCode,
+ companyId: companies[0].companyId
+ })
+ }
+ console.log('======================================')
+
+ return {
+ success: true,
+ data: companies
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '업체 목록 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선택된 업체들에게 사전견적 초대 발송
+interface CompanyWithContacts {
+ id: number
+ companyId: number
+ companyName: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+}
+
+export async function sendPreQuoteInvitations(companiesData: CompanyWithContacts[], preQuoteDeadline?: Date | string) {
+ try {
+ console.log('=== sendPreQuoteInvitations called ===');
+ console.log('companiesData:', JSON.stringify(companiesData, null, 2));
+
+ if (companiesData.length === 0) {
+ return {
+ success: false,
+ error: '선택된 업체가 없습니다.'
+ }
+ }
+
+ const companyIds = companiesData.map(c => c.id);
+ console.log('companyIds:', companyIds);
+
+ // 선택된 업체들의 정보와 입찰 정보 조회
+ const companiesInfo = await db
+ .select({
+ biddingCompanyId: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ biddingId: biddingCompanies.biddingId,
+ companyName: vendors.vendorName,
+ companyEmail: vendors.email,
+ // 입찰 정보
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ biddingTitle: biddings.title,
+ itemName: biddings.itemName,
+ preQuoteDate: biddings.preQuoteDate,
+ budget: biddings.budget,
+ currency: biddings.currency,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .leftJoin(biddings, eq(biddingCompanies.biddingId, biddings.id))
+ .where(inArray(biddingCompanies.id, companyIds))
+
+ console.log('companiesInfo fetched:', JSON.stringify(companiesInfo, null, 2));
+
+ if (companiesInfo.length === 0) {
+ return {
+ success: false,
+ error: '업체 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 모든 필드가 null이 아닌지 확인하고 안전하게 변환
+ const safeCompaniesInfo = companiesInfo.map(company => ({
+ ...company,
+ companyName: company.companyName ?? '',
+ companyEmail: company.companyEmail ?? '',
+ biddingNumber: company.biddingNumber ?? '',
+ revision: company.revision ?? '',
+ projectName: company.projectName ?? '',
+ biddingTitle: company.biddingTitle ?? '',
+ itemName: company.itemName ?? '',
+ preQuoteDate: company.preQuoteDate ?? null,
+ budget: company.budget ?? null,
+ currency: company.currency ?? '',
+ bidPicName: company.bidPicName ?? '',
+ supplyPicName: company.supplyPicName ?? '',
+ }));
+
+ console.log('safeCompaniesInfo prepared:', JSON.stringify(safeCompaniesInfo, null, 2));
+
+ await db.transaction(async (tx) => {
+ // 선택된 업체들의 상태를 '사전견적 초대 발송'으로 변경
+ for (const id of companyIds) {
+ await tx.update(biddingCompanies)
+ .set({
+ invitationStatus: 'pre_quote_sent', // 사전견적 초대 발송 상태
+ invitedAt: new Date(),
+ preQuoteDeadline: preQuoteDeadline ? new Date(preQuoteDeadline) : null,
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, id))
+ }
+ })
+
+ // 각 업체별로 이메일 발송 (담당자 정보 포함)
+ console.log('=== Starting email sending ===');
+ for (const company of safeCompaniesInfo) {
+ console.log(`Processing company: ${company.companyName} (biddingCompanyId: ${company.biddingCompanyId})`);
+
+ const companyData = companiesData.find(c => c.id === company.biddingCompanyId);
+ if (!companyData) {
+ console.log(`No companyData found for biddingCompanyId: ${company.biddingCompanyId}`);
+ continue;
+ }
+
+ console.log('companyData found:', JSON.stringify(companyData, null, 2));
+
+ const mainEmail = companyData.selectedMainEmail || '';
+ const ccEmails = Array.isArray(companyData.additionalEmails) ? companyData.additionalEmails : [];
+
+ console.log(`mainEmail: ${mainEmail}, ccEmails: ${JSON.stringify(ccEmails)}`);
+
+ if (mainEmail) {
+ try {
+ console.log('Preparing to send email...');
+
+ const emailContext = {
+ companyName: company.companyName,
+ biddingNumber: company.biddingNumber,
+ revision: company.revision,
+ projectName: company.projectName,
+ biddingTitle: company.biddingTitle,
+ itemName: company.itemName,
+ preQuoteDate: company.preQuoteDate ? new Date(company.preQuoteDate).toLocaleDateString() : '',
+ budget: company.budget ? String(company.budget) : '',
+ currency: company.currency,
+ bidPicName: company.bidPicName,
+ supplyPicName: company.supplyPicName,
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${company.biddingId}/pre-quote`,
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ };
+
+ console.log('Email context prepared:', JSON.stringify(emailContext, null, 2));
+
+ await sendEmail({
+ to: mainEmail,
+ cc: ccEmails.length > 0 ? ccEmails : undefined,
+ template: 'pre-quote-invitation',
+ context: emailContext
+ })
+
+ console.log(`Email sent successfully to ${mainEmail}`);
+ } catch (emailError) {
+ console.error(`Failed to send email to ${mainEmail}:`, emailError)
+ // 이메일 발송 실패해도 전체 프로세스는 계속 진행
+ }
+ }
+ }
+ // 3. 입찰 상태를 사전견적 요청으로 변경 (bidding_generated 상태에서만)
+ for (const company of companiesInfo) {
+ await db.transaction(async (tx) => {
+ await tx
+ .update(biddings)
+ .set({
+ status: 'request_for_quotation',
+ updatedAt: new Date()
+ })
+ .where(and(
+ eq(biddings.id, company.biddingId),
+ eq(biddings.status, 'bidding_generated')
+ ))
+ })
+ }
+ return {
+ success: true,
+ message: `${companyIds.length}개 업체에 사전견적 초대를 발송했습니다.`
+ }
+ } catch (error) {
+ console.error('Failed to send pre-quote invitations:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '초대 발송에 실패했습니다.'
+ }
+ }
+}
+
+// Partners에서 특정 업체의 입찰 정보 조회 (사전견적 단계)
+export async function getBiddingCompaniesForPartners(biddingId: number, companyId: number) {
+ try {
+ // 1. 먼저 입찰 기본 정보를 가져옴
+ const biddingResult = await db
+ .select({
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ title: biddings.title,
+ description: biddings.description,
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
+ preQuoteDate: biddings.preQuoteDate,
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ evaluationDate: biddings.evaluationDate,
+ currency: biddings.currency,
+ budget: biddings.budget,
+ targetPrice: biddings.targetPrice,
+ status: biddings.status,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (biddingResult.length === 0) {
+ return null
+ }
+
+ const biddingData = biddingResult[0]
+
+ // 2. 해당 업체의 biddingCompanies 정보 조회
+ const companyResult = await db
+ .select({
+ biddingCompanyId: biddingCompanies.id,
+ biddingId: biddingCompanies.biddingId,
+ invitationStatus: biddingCompanies.invitationStatus,
+ preQuoteAmount: biddingCompanies.preQuoteAmount,
+ preQuoteSubmittedAt: biddingCompanies.preQuoteSubmittedAt,
+ preQuoteDeadline: biddingCompanies.preQuoteDeadline,
+ isPreQuoteSelected: biddingCompanies.isPreQuoteSelected,
+ isPreQuoteParticipated: biddingCompanies.isPreQuoteParticipated,
+ isAttendingMeeting: biddingCompanies.isAttendingMeeting,
+ // company_condition_responses 정보
+ paymentTermsResponse: companyConditionResponses.paymentTermsResponse,
+ taxConditionsResponse: companyConditionResponses.taxConditionsResponse,
+ incotermsResponse: companyConditionResponses.incotermsResponse,
+ proposedContractDeliveryDate: companyConditionResponses.proposedContractDeliveryDate,
+ proposedShippingPort: companyConditionResponses.proposedShippingPort,
+ proposedDestinationPort: companyConditionResponses.proposedDestinationPort,
+ priceAdjustmentResponse: companyConditionResponses.priceAdjustmentResponse,
+ sparePartResponse: companyConditionResponses.sparePartResponse,
+ isInitialResponse: companyConditionResponses.isInitialResponse,
+ additionalProposals: companyConditionResponses.additionalProposals,
+ })
+ .from(biddingCompanies)
+ .leftJoin(
+ companyConditionResponses,
+ eq(biddingCompanies.id, companyConditionResponses.biddingCompanyId)
+ )
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, companyId)
+ )
+ )
+ .limit(1)
+
+ // 3. 결과 조합
+ if (companyResult.length === 0) {
+ // 아직 초대되지 않은 상태
+ return {
+ ...biddingData,
+ biddingCompanyId: null,
+ biddingId: biddingData.id,
+ invitationStatus: null,
+ preQuoteAmount: null,
+ preQuoteSubmittedAt: null,
+ preQuoteDeadline: null,
+ isPreQuoteSelected: false,
+ isPreQuoteParticipated: null,
+ isAttendingMeeting: null,
+ paymentTermsResponse: null,
+ taxConditionsResponse: null,
+ incotermsResponse: null,
+ proposedContractDeliveryDate: null,
+ proposedShippingPort: null,
+ proposedDestinationPort: null,
+ priceAdjustmentResponse: null,
+ sparePartResponse: null,
+ isInitialResponse: null,
+ additionalProposals: null,
+ }
+ }
+
+ const companyData = companyResult[0]
+
+ return {
+ ...biddingData,
+ ...companyData,
+ biddingId: biddingData.id, // bidding ID 보장
+ }
+ } catch (error) {
+ console.error('Failed to get bidding companies for partners:', error)
+ throw error
+ }
+}
+
+// Partners에서 사전견적 응답 제출
+export async function submitPreQuoteResponse(
+ biddingCompanyId: number,
+ responseData: {
+ preQuoteAmount?: number // 품목별 계산에서 자동으로 계산되므로 optional
+ prItemQuotations?: PrItemQuotation[] // 품목별 견적 정보 추가
+ paymentTermsResponse?: string
+ taxConditionsResponse?: string
+ incotermsResponse?: string
+ proposedContractDeliveryDate?: string
+ proposedShippingPort?: string
+ proposedDestinationPort?: string
+ priceAdjustmentResponse?: boolean
+ isInitialResponse?: boolean
+ sparePartResponse?: string
+ additionalProposals?: string
+ priceAdjustmentForm?: any
+ },
+ userId: string
+) {
+ try {
+ let finalAmount = responseData.preQuoteAmount || 0
+
+ await db.transaction(async (tx) => {
+ // 1. 품목별 견적 정보 최종 저장 (사전견적 제출)
+ if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
+ // 기존 사전견적 품목 삭제 후 새로 생성
+ await tx.delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // 품목별 견적 최종 저장
+ for (const item of responseData.prItemQuotations) {
+ await tx.insert(companyPrItemBids)
+ .values({
+ biddingCompanyId,
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice.toString(),
+ bidAmount: item.bidAmount.toString(),
+ proposedDeliveryDate: item.proposedDeliveryDate || null,
+ technicalSpecification: item.technicalSpecification || null,
+ currency: 'KRW',
+ isPreQuote: true,
+ submittedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+
+ // 총 금액 다시 계산
+ finalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
+ }
+
+ // 2. biddingCompanies 업데이트 (사전견적 금액, 제출 시간, 상태 변경)
+ await tx.update(biddingCompanies)
+ .set({
+ preQuoteAmount: finalAmount.toString(),
+ preQuoteSubmittedAt: new Date(),
+ invitationStatus: 'pre_quote_submitted', // 사전견적제출완료 상태로 변경
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ // 3. company_condition_responses 업데이트
+ const finalConditionResult = await tx.update(companyConditionResponses)
+ .set({
+ paymentTermsResponse: responseData.paymentTermsResponse,
+ taxConditionsResponse: responseData.taxConditionsResponse,
+ incotermsResponse: responseData.incotermsResponse,
+ proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
+ proposedShippingPort: responseData.proposedShippingPort,
+ proposedDestinationPort: responseData.proposedDestinationPort,
+ priceAdjustmentResponse: responseData.priceAdjustmentResponse,
+ isInitialResponse: responseData.isInitialResponse,
+ sparePartResponse: responseData.sparePartResponse,
+ additionalProposals: responseData.additionalProposals,
+ updatedAt: new Date()
+ })
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .returning()
+
+ // 4. 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
+ if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && finalConditionResult.length > 0) {
+ const companyConditionResponseId = finalConditionResult[0].id
+
+ const priceAdjustmentData = {
+ companyConditionResponsesId: companyConditionResponseId,
+ itemName: responseData.priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ } as any
+
+ // 기존 연동제 정보가 있는지 확인
+ const existingPriceAdjustment = await tx
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ if (existingPriceAdjustment.length > 0) {
+ // 업데이트
+ await tx
+ .update(priceAdjustmentForms)
+ .set(priceAdjustmentData)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ } else {
+ // 새로 생성
+ await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
+ }
+ }
+
+ // 5. 입찰 상태를 사전견적 접수로 변경 (request_for_quotation 상태에서만)
+ // 또한 사전견적 접수일 업데이트
+ const biddingCompany = await tx
+ .select({ biddingId: biddingCompanies.biddingId })
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ .limit(1)
+
+ if (biddingCompany.length > 0) {
+ await tx
+ .update(biddings)
+ .set({
+ status: 'received_quotation',
+ preQuoteDate: new Date().toISOString().split('T')[0], // 사전견적 접수일 업데이트
+ updatedAt: new Date()
+ })
+ .where(and(
+ eq(biddings.id, biddingCompany[0].biddingId),
+ eq(biddings.status, 'request_for_quotation')
+ ))
+ }
+ })
+
+ return {
+ success: true,
+ message: '사전견적이 성공적으로 제출되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to submit pre-quote response:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '사전견적 제출에 실패했습니다.'
+ }
+ }
+}
+
+// Partners에서 사전견적 참여 의사 결정 (참여/미참여)
+export async function respondToPreQuoteInvitation(
+ biddingCompanyId: number,
+ response: 'pre_quote_accepted' | 'pre_quote_declined'
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ invitationStatus: response, // pre_quote_accepted 또는 pre_quote_declined
+ respondedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ const message = response === 'pre_quote_accepted' ?
+ '사전견적 참여를 수락했습니다.' :
+ '사전견적 참여를 거절했습니다.'
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to respond to pre-quote invitation:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '응답 처리에 실패했습니다.'
+ }
+ }
+}
+
+// 벤더에서 사전견적 참여 여부 결정 (isPreQuoteParticipated 사용)
+export async function setPreQuoteParticipation(
+ biddingCompanyId: number,
+ isParticipating: boolean
+) {
+ try {
+ await db.update(biddingCompanies)
+ .set({
+ isPreQuoteParticipated: isParticipating,
+ respondedAt: new Date(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ const message = isParticipating ?
+ '사전견적 참여를 확정했습니다. 이제 견적서를 작성하실 수 있습니다.' :
+ '사전견적 참여를 거절했습니다.'
+
+ return {
+ success: true,
+ message
+ }
+ } catch (error) {
+ console.error('Failed to set pre-quote participation:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '참여 의사 처리에 실패했습니다.'
+ }
+ }
+}
+
+// PR 아이템 조회 (입찰에 포함된 품목들)
+export async function getPrItemsForBidding(biddingId: number) {
+ try {
+ const prItems = await db
+ .select({
+ id: prItemsForBidding.id,
+ biddingId: prItemsForBidding.biddingId,
+ itemNumber: prItemsForBidding.itemNumber,
+ projectId: prItemsForBidding.projectId,
+ projectInfo: prItemsForBidding.projectInfo,
+ itemInfo: prItemsForBidding.itemInfo,
+ shi: prItemsForBidding.shi,
+ materialGroupNumber: prItemsForBidding.materialGroupNumber,
+ materialGroupInfo: prItemsForBidding.materialGroupInfo,
+ materialNumber: prItemsForBidding.materialNumber,
+ materialInfo: prItemsForBidding.materialInfo,
+ requestedDeliveryDate: prItemsForBidding.requestedDeliveryDate,
+ annualUnitPrice: prItemsForBidding.annualUnitPrice,
+ currency: prItemsForBidding.currency,
+ quantity: prItemsForBidding.quantity,
+ quantityUnit: prItemsForBidding.quantityUnit,
+ totalWeight: prItemsForBidding.totalWeight,
+ weightUnit: prItemsForBidding.weightUnit,
+ priceUnit: prItemsForBidding.priceUnit,
+ purchaseUnit: prItemsForBidding.purchaseUnit,
+ materialWeight: prItemsForBidding.materialWeight,
+ prNumber: prItemsForBidding.prNumber,
+ hasSpecDocument: prItemsForBidding.hasSpecDocument,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ return prItems
+ } catch (error) {
+ console.error('Failed to get PR items for bidding:', error)
+ return []
+ }
+}
+
+// SPEC 문서 조회 (PR 아이템에 연결된 문서들)
+export async function getSpecDocumentsForPrItem(prItemId: number) {
+ try {
+
+ const specDocs = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.prItemId, prItemId),
+ eq(biddingDocuments.documentType, 'spec_document')
+ )
+ )
+
+ return specDocs
+ } catch (error) {
+ console.error('Failed to get spec documents for PR item:', error)
+ return []
+ }
+}
+
+// 사전견적 임시저장
+export async function savePreQuoteDraft(
+ biddingCompanyId: number,
+ responseData: {
+ prItemQuotations?: PrItemQuotation[]
+ paymentTermsResponse?: string
+ taxConditionsResponse?: string
+ incotermsResponse?: string
+ proposedContractDeliveryDate?: string
+ proposedShippingPort?: string
+ proposedDestinationPort?: string
+ priceAdjustmentResponse?: boolean
+ isInitialResponse?: boolean
+ sparePartResponse?: string
+ additionalProposals?: string
+ priceAdjustmentForm?: any
+ },
+ userId: string
+) {
+ try {
+ let totalAmount = 0
+ console.log('responseData', responseData)
+
+ await db.transaction(async (tx) => {
+ // 품목별 견적 정보 저장
+ if (responseData.prItemQuotations && responseData.prItemQuotations.length > 0) {
+ // 기존 사전견적 품목 삭제 (임시저장 시 덮어쓰기)
+ await tx.delete(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // 새로운 품목별 견적 저장
+ for (const item of responseData.prItemQuotations) {
+ await tx.insert(companyPrItemBids)
+ .values({
+ biddingCompanyId,
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice.toString(),
+ bidAmount: item.bidAmount.toString(),
+ proposedDeliveryDate: item.proposedDeliveryDate || null,
+ technicalSpecification: item.technicalSpecification || null,
+ currency: 'KRW',
+ isPreQuote: true, // 사전견적 표시
+ submittedAt: new Date(),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+
+ // 총 금액 계산
+ totalAmount = responseData.prItemQuotations.reduce((sum, item) => sum + item.bidAmount, 0)
+
+ // biddingCompanies에 총 금액 임시 저장 (status는 변경하지 않음)
+ await tx.update(biddingCompanies)
+ .set({
+ preQuoteAmount: totalAmount.toString(),
+ updatedAt: new Date()
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+ }
+
+ // company_condition_responses 업데이트 (임시저장)
+ const conditionResult = await tx.update(companyConditionResponses)
+ .set({
+ paymentTermsResponse: responseData.paymentTermsResponse || null,
+ taxConditionsResponse: responseData.taxConditionsResponse || null,
+ incotermsResponse: responseData.incotermsResponse || null,
+ proposedContractDeliveryDate: responseData.proposedContractDeliveryDate || null,
+ proposedShippingPort: responseData.proposedShippingPort || null,
+ proposedDestinationPort: responseData.proposedDestinationPort || null,
+ priceAdjustmentResponse: responseData.priceAdjustmentResponse || null,
+ isInitialResponse: responseData.isInitialResponse || null,
+ sparePartResponse: responseData.sparePartResponse || null,
+ additionalProposals: responseData.additionalProposals || null,
+ updatedAt: new Date()
+ })
+ .where(eq(companyConditionResponses.biddingCompanyId, biddingCompanyId))
+ .returning()
+
+ // 연동제 정보 저장 (연동제 적용이 true이고 연동제 정보가 있는 경우)
+ if (responseData.priceAdjustmentResponse && responseData.priceAdjustmentForm && conditionResult.length > 0) {
+ const companyConditionResponseId = conditionResult[0].id
+
+ const priceAdjustmentData = {
+ companyConditionResponsesId: companyConditionResponseId,
+ itemName: responseData.priceAdjustmentForm.itemName,
+ adjustmentReflectionPoint: responseData.priceAdjustmentForm.adjustmentReflectionPoint,
+ majorApplicableRawMaterial: responseData.priceAdjustmentForm.majorApplicableRawMaterial,
+ adjustmentFormula: responseData.priceAdjustmentForm.adjustmentFormula,
+ rawMaterialPriceIndex: responseData.priceAdjustmentForm.rawMaterialPriceIndex,
+ referenceDate: responseData.priceAdjustmentForm.referenceDate as string || null,
+ comparisonDate: responseData.priceAdjustmentForm.comparisonDate as string || null,
+ adjustmentRatio: responseData.priceAdjustmentForm.adjustmentRatio || null,
+ notes: responseData.priceAdjustmentForm.notes,
+ adjustmentConditions: responseData.priceAdjustmentForm.adjustmentConditions,
+ majorNonApplicableRawMaterial: responseData.priceAdjustmentForm.majorNonApplicableRawMaterial,
+ adjustmentPeriod: responseData.priceAdjustmentForm.adjustmentPeriod,
+ contractorWriter: responseData.priceAdjustmentForm.contractorWriter,
+ adjustmentDate: responseData.priceAdjustmentForm.adjustmentDate as string || null,
+ nonApplicableReason: responseData.priceAdjustmentForm.nonApplicableReason,
+ } as any
+
+ // 기존 연동제 정보가 있는지 확인
+ const existingPriceAdjustment = await tx
+ .select()
+ .from(priceAdjustmentForms)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ .limit(1)
+
+ if (existingPriceAdjustment.length > 0) {
+ // 업데이트
+ await tx
+ .update(priceAdjustmentForms)
+ .set(priceAdjustmentData)
+ .where(eq(priceAdjustmentForms.companyConditionResponsesId, companyConditionResponseId))
+ } else {
+ // 새로 생성
+ await tx.insert(priceAdjustmentForms).values(priceAdjustmentData)
+ }
+ }
+ })
+
+ return {
+ success: true,
+ message: '임시저장이 완료되었습니다.',
+ totalAmount
+ }
+ } catch (error) {
+ console.error('Failed to save pre-quote draft:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '임시저장에 실패했습니다.'
+ }
+ }
+}
+
+// 견적 문서 업로드
+export async function uploadPreQuoteDocument(
+ biddingId: number,
+ companyId: number,
+ file: File,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+ // 파일 저장
+ const saveResult = await saveFile({
+ file,
+ directory: `bidding/${biddingId}/quotations`,
+ originalName: file.name,
+ userId
+ })
+
+ if (!saveResult.success) {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+
+ // 데이터베이스에 문서 정보 저장
+ const result = await db.insert(biddingDocuments)
+ .values({
+ biddingId,
+ companyId,
+ documentType: 'other', // 견적서 타입
+ fileName: saveResult.fileName!,
+ originalFileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type,
+ filePath: saveResult.publicPath!, // publicPath 사용 (웹 접근 가능한 경로)
+ title: `견적서 - ${file.name}`,
+ description: '협력업체 제출 견적서',
+ isPublic: false,
+ isRequired: false,
+ uploadedBy: userName,
+ uploadedAt: new Date()
+ })
+ .returning()
+
+ return {
+ success: true,
+ message: '견적서가 성공적으로 업로드되었습니다.',
+ documentId: result[0].id
+ }
+ } catch (error) {
+ console.error('Failed to upload pre-quote document:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '견적서 업로드에 실패했습니다.'
+ }
+ }
+}
+
+// 업로드된 견적 문서 목록 조회
+export async function getPreQuoteDocuments(biddingId: number, companyId: number) {
+ try {
+ const documents = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ fileSize: biddingDocuments.fileSize,
+ filePath: biddingDocuments.filePath,
+ title: biddingDocuments.title,
+ description: biddingDocuments.description,
+ uploadedAt: biddingDocuments.uploadedAt,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ )
+ )
+
+ return documents
+ } catch (error) {
+ console.error('Failed to get pre-quote documents:', error)
+ return []
+ }
+ }
+
+// 저장된 품목별 견적 조회 (임시저장/기존 데이터 불러오기용)
+export async function getSavedPrItemQuotations(biddingCompanyId: number) {
+ try {
+ const savedQuotations = await db
+ .select({
+ prItemId: companyPrItemBids.prItemId,
+ bidUnitPrice: companyPrItemBids.bidUnitPrice,
+ bidAmount: companyPrItemBids.bidAmount,
+ proposedDeliveryDate: companyPrItemBids.proposedDeliveryDate,
+ technicalSpecification: companyPrItemBids.technicalSpecification,
+ currency: companyPrItemBids.currency
+ })
+ .from(companyPrItemBids)
+ .where(
+ and(
+ eq(companyPrItemBids.biddingCompanyId, biddingCompanyId),
+ eq(companyPrItemBids.isPreQuote, true)
+ )
+ )
+
+ // Decimal 타입을 number로 변환
+ return savedQuotations.map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: parseFloat(item.bidUnitPrice || '0'),
+ bidAmount: parseFloat(item.bidAmount || '0'),
+ proposedDeliveryDate: item.proposedDeliveryDate,
+ technicalSpecification: item.technicalSpecification,
+ currency: item.currency
+ }))
+ } catch (error) {
+ console.error('Failed to get saved PR item quotations:', error)
+ return []
+ }
+ }
+
+// 견적 문서 정보 조회 (다운로드용)
+export async function getPreQuoteDocumentForDownload(
+ documentId: number,
+ biddingId: number,
+ companyId: number
+) {
+ try {
+ const document = await db
+ .select({
+ fileName: biddingDocuments.fileName,
+ originalFileName: biddingDocuments.originalFileName,
+ filePath: biddingDocuments.filePath
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ eq(biddingDocuments.documentType, 'other')
+ )
+ )
+ .limit(1)
+
+ if (document.length === 0) {
+ return {
+ success: false,
+ error: '문서를 찾을 수 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ document: document[0]
+ }
+ } catch (error) {
+ console.error('Failed to get pre-quote document:', error)
+ return {
+ success: false,
+ error: '문서 정보 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 견적 문서 삭제
+export async function deletePreQuoteDocument(
+ documentId: number,
+ biddingId: number,
+ companyId: number,
+ userId: string
+) {
+ try {
+ // 문서 존재 여부 및 권한 확인
+ const document = await db
+ .select({
+ id: biddingDocuments.id,
+ fileName: biddingDocuments.fileName,
+ filePath: biddingDocuments.filePath,
+ uploadedBy: biddingDocuments.uploadedBy
+ })
+ .from(biddingDocuments)
+ .where(
+ and(
+ eq(biddingDocuments.id, documentId),
+ eq(biddingDocuments.biddingId, biddingId),
+ eq(biddingDocuments.companyId, companyId),
+ eq(biddingDocuments.documentType, 'other')
+ )
+ )
+ .limit(1)
+
+ if (document.length === 0) {
+ return {
+ success: false,
+ error: '문서를 찾을 수 없습니다.'
+ }
+ }
+
+ const doc = document[0]
+
+ // 데이터베이스에서 문서 정보 삭제
+ await db
+ .delete(biddingDocuments)
+ .where(eq(biddingDocuments.id, documentId))
+
+ return {
+ success: true,
+ message: '문서가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete pre-quote document:', error)
+ return {
+ success: false,
+ error: '문서 삭제에 실패했습니다.'
+ }
+ }
+ }
+
+// 기본계약 발송 (서버 액션)
+export async function sendBiddingBasicContracts(
+ biddingId: number,
+ vendorData: Array<{
+ vendorId: number
+ vendorName: string
+ vendorCode?: string
+ vendorCountry?: string
+ selectedMainEmail: string
+ additionalEmails: string[]
+ customEmails?: Array<{ email: string; name?: string }>
+ contractRequirements: {
+ ndaYn: boolean
+ generalGtcYn: boolean
+ projectGtcYn: boolean
+ agreementYn: boolean
+ }
+ biddingCompanyId: number
+ biddingId: number
+ hasExistingContracts?: boolean
+ }>,
+ generatedPdfs: Array<{
+ key: string
+ buffer: number[]
+ fileName: string
+ }>,
+ message?: string
+) {
+ try {
+ console.log("sendBiddingBasicContracts called with:", { biddingId, vendorData: vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })) });
+
+ // 현재 사용자 정보 조회 (임시로 첫 번째 사용자 사용)
+ const [currentUser] = await db.select().from(users).limit(1)
+
+ if (!currentUser) {
+ throw new Error("사용자 정보를 찾을 수 없습니다.")
+ }
+
+ const results = []
+ const savedContracts = []
+
+ // 트랜잭션 시작
+ const contractsDir = path.join(process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated");
+ await mkdir(contractsDir, { recursive: true });
+
+ const result = await db.transaction(async (tx) => {
+ // 각 벤더별로 기본계약 생성 및 이메일 발송
+ for (const vendor of vendorData) {
+ // 기존 계약 확인 (biddingCompanyId 기준)
+ if (vendor.hasExistingContracts) {
+ console.log(`벤더 ${vendor.vendorName}는 이미 계약이 체결되어 있어 건너뜁니다.`)
+ continue
+ }
+
+ // 벤더 정보 조회
+ const [vendorInfo] = await tx
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, vendor.vendorId))
+ .limit(1)
+
+ if (!vendorInfo) {
+ console.error(`벤더 정보를 찾을 수 없습니다: ${vendor.vendorId}`)
+ continue
+ }
+
+ // biddingCompany 정보 조회 (biddingCompanyId를 직접 사용)
+ console.log(`Looking for biddingCompany with id=${vendor.biddingCompanyId}`)
+ let [biddingCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.id, vendor.biddingCompanyId))
+ .limit(1)
+
+ console.log(`Found biddingCompanyInfo:`, biddingCompanyInfo)
+ if (!biddingCompanyInfo) {
+ console.error(`입찰 회사 정보를 찾을 수 없습니다: biddingCompanyId=${vendor.biddingCompanyId}`)
+ // fallback: biddingId와 vendorId로 찾기 시도
+ console.log(`Fallback: Looking for biddingCompany with biddingId=${biddingId}, companyId=${vendor.vendorId}`)
+ const [fallbackCompanyInfo] = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.companyId, vendor.vendorId)
+ ))
+ .limit(1)
+ console.log(`Fallback found biddingCompanyInfo:`, fallbackCompanyInfo)
+ if (fallbackCompanyInfo) {
+ console.log(`Using fallback biddingCompanyInfo`)
+ biddingCompanyInfo = fallbackCompanyInfo
+ } else {
+ console.log(`Available biddingCompanies for biddingId=${biddingId}:`, await tx.select().from(biddingCompanies).where(eq(biddingCompanies.biddingId, biddingId)).limit(10))
+ continue
+ }
+ }
+
+ // 계약 요구사항에 따라 계약서 생성
+ const contractTypes: Array<{ type: string; templateName: string }> = []
+ if (vendor.contractRequirements?.ndaYn) contractTypes.push({ type: 'NDA', templateName: '비밀' })
+ if (vendor.contractRequirements?.generalGtcYn) contractTypes.push({ type: 'General_GTC', templateName: 'General GTC' })
+ if (vendor.contractRequirements?.projectGtcYn) contractTypes.push({ type: 'Project_GTC', templateName: '기술' })
+ if (vendor.contractRequirements?.agreementYn) contractTypes.push({ type: '기술자료', templateName: '기술자료' })
+
+ // contractRequirements가 없거나 빈 객체인 경우 빈 배열로 처리
+ if (!vendor.contractRequirements || Object.keys(vendor.contractRequirements).length === 0) {
+ console.log(`Skipping vendor ${vendor.vendorId} - no contract requirements specified`)
+ continue
+ }
+
+ console.log("contractTypes", contractTypes)
+ for (const contractType of contractTypes) {
+ // PDF 데이터 찾기 (include를 사용하여 유연하게 찾기)
+ console.log("generatedPdfs", generatedPdfs.map(pdf => pdf.key))
+ const pdfData = generatedPdfs.find((pdf: any) =>
+ pdf.key.includes(`${vendor.vendorId}_`) &&
+ pdf.key.includes(`_${contractType.templateName}`)
+ )
+ console.log("pdfData", pdfData, "for contractType", contractType)
+ if (!pdfData) {
+ console.error(`PDF 데이터를 찾을 수 없습니다: vendorId=${vendor.vendorId}, templateName=${contractType.templateName}`)
+ continue
+ }
+
+ // 파일 저장 (rfq-last 방식)
+ const fileName = `${contractType.type}_${vendor.vendorCode || vendor.vendorId}_${vendor.biddingCompanyId}_${Date.now()}.pdf`
+ const filePath = path.join(contractsDir, fileName);
+
+ await writeFile(filePath, Buffer.from(pdfData.buffer));
+
+ // 템플릿 정보 조회 (rfq-last 방식)
+ const [template] = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ ilike(basicContractTemplates.templateName, `%${contractType.templateName}%`),
+ eq(basicContractTemplates.status, "ACTIVE")
+ )
+ )
+ .limit(1);
+
+ console.log("템플릿", contractType.templateName, template);
+
+ // 기존 계약이 있는지 확인 (rfq-last 방식)
+ const [existingContract] = await tx
+ .select()
+ .from(basicContract)
+ .where(
+ and(
+ eq(basicContract.templateId, template?.id),
+ eq(basicContract.vendorId, vendor.vendorId),
+ eq(basicContract.biddingCompanyId, biddingCompanyInfo.id)
+ )
+ )
+ .limit(1);
+
+ let contractRecord;
+
+ if (existingContract) {
+ // 기존 계약이 있으면 업데이트
+ [contractRecord] = await tx
+ .update(basicContract)
+ .set({
+ requestedBy: currentUser.id,
+ status: "PENDING", // 재발송 상태
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000),
+ updatedAt: new Date(),
+ })
+ .where(eq(basicContract.id, existingContract.id))
+ .returning();
+
+ console.log("기존 계약 업데이트:", contractRecord.id);
+ } else {
+ // 새 계약 생성
+ [contractRecord] = await tx
+ .insert(basicContract)
+ .values({
+ templateId: template?.id || null,
+ vendorId: vendor.vendorId,
+ biddingCompanyId: biddingCompanyInfo.id,
+ rfqCompanyId: null,
+ generalContractId: null,
+ requestedBy: currentUser.id,
+ status: 'PENDING',
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10일 후
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ console.log("새 계약 생성:", contractRecord.id);
+ }
+
+ results.push({
+ vendorId: vendor.vendorId,
+ vendorName: vendor.vendorName,
+ contractId: contractRecord.id,
+ contractType: contractType.type,
+ fileName: fileName,
+ filePath: `/contracts/generated/${fileName}`,
+ })
+
+ // savedContracts에 추가 (rfq-last 방식)
+ // savedContracts.push({
+ // vendorId: vendor.vendorId,
+ // vendorName: vendor.vendorName,
+ // templateName: contractType.templateName,
+ // contractId: contractRecord.id,
+ // fileName: fileName,
+ // isUpdated: !!existingContract, // 업데이트 여부 표시
+ // })
+ }
+
+ // 이메일 발송 (선택사항)
+ if (vendor.selectedMainEmail) {
+ try {
+ await sendEmail({
+ to: vendor.selectedMainEmail,
+ template: 'basic-contract-notification',
+ context: {
+ vendorName: vendor.vendorName,
+ biddingId: biddingId,
+ contractCount: contractTypes.length,
+ deadline: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toLocaleDateString('ko-KR'),
+ loginUrl: `${process.env.NEXT_PUBLIC_APP_URL}/partners/bid/${biddingId}`,
+ message: message || '',
+ currentYear: new Date().getFullYear(),
+ language: 'ko'
+ }
+ })
+ } catch (emailError) {
+ console.error(`이메일 발송 실패 (${vendor.selectedMainEmail}):`, emailError)
+ // 이메일 발송 실패해도 계약 생성은 유지
+ }
+ }
+ }
+
+ return {
+ success: true,
+ message: `${results.length}개의 기본계약이 생성되었습니다.`,
+ results,
+ savedContracts,
+ totalContracts: savedContracts.length,
+ }
+ })
+
+ return result
+
+ } catch (error) {
+ console.error('기본계약 발송 실패:', error)
+ throw new Error(
+ error instanceof Error
+ ? error.message
+ : '기본계약 발송 중 오류가 발생했습니다.'
+ )
+ }
+}
+
+// 기존 기본계약 조회 (서버 액션)
+export async function getExistingBasicContractsForBidding(biddingId: number) {
+ try {
+ // 해당 biddingId에 속한 biddingCompany들의 기존 기본계약 조회
+ const existingContracts = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ biddingCompanyId: basicContract.biddingCompanyId,
+ biddingId: biddingCompanies.biddingId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ })
+ .from(basicContract)
+ .leftJoin(biddingCompanies, eq(basicContract.biddingCompanyId, biddingCompanies.id))
+ .where(
+ and(
+ eq(biddingCompanies.biddingId, biddingId),
+ )
+ )
+
+ return {
+ success: true,
+ contracts: existingContracts
+ }
+
+ } catch (error) {
+ console.error('기존 계약 조회 실패:', error)
+ return {
+ success: false,
+ error: '기존 계약 조회에 실패했습니다.'
+ }
+ }
+}
+
+// 선정된 업체들 조회 (서버 액션)
+export async function getSelectedVendorsForBidding(biddingId: number) {
+ try {
+ const selectedCompanies = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId,
+ companyName: vendors.vendorName,
+ companyCode: vendors.vendorCode,
+ companyEmail: vendors.email,
+ companyCountry: vendors.country,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ biddingId: biddingCompanies.biddingId,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(and(
+ eq(biddingCompanies.biddingId, biddingId),
+ eq(biddingCompanies.isPreQuoteSelected, true)
+ ))
+
+ return {
+ success: true,
+ vendors: selectedCompanies.map(company => ({
+ vendorId: company.companyId, // 실제 vendor ID
+ vendorName: company.companyName || '',
+ vendorCode: company.companyCode,
+ vendorEmail: company.companyEmail,
+ vendorCountry: company.companyCountry || '대한민국',
+ contactPerson: company.contactPerson,
+ contactEmail: company.contactEmail,
+ biddingCompanyId: company.id, // biddingCompany ID
+ biddingId: company.biddingId,
+ ndaYn: true, // 모든 계약 타입을 활성화 (필요에 따라 조정)
+ generalGtcYn: true,
+ projectGtcYn: true,
+ agreementYn: true
+ }))
+ }
+ } catch (error) {
+ console.error('선정된 업체 조회 실패:', error)
+ return {
+ success: false,
+ error: '선정된 업체 조회에 실패했습니다.',
+ vendors: []
+ }
+ }
} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx
deleted file mode 100644
index cfa629e3..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-attachments-dialog.tsx
+++ /dev/null
@@ -1,224 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Badge } from '@/components/ui/badge'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
-import {
- FileText,
- Download,
- User,
- Calendar
-} from 'lucide-react'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { getPreQuoteDocuments, getPreQuoteDocumentForDownload } from '../service'
-import { downloadFile } from '@/lib/file-download'
-
-interface UploadedDocument {
- id: number
- fileName: string
- originalFileName: string
- fileSize: number | null
- filePath: string
- title: string | null
- description: string | null
- uploadedAt: string
- uploadedBy: string
-}
-
-interface BiddingPreQuoteAttachmentsDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- biddingId: number
- companyId: number
- companyName: string
-}
-
-export function BiddingPreQuoteAttachmentsDialog({
- open,
- onOpenChange,
- biddingId,
- companyId,
- companyName
-}: BiddingPreQuoteAttachmentsDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [documents, setDocuments] = React.useState<UploadedDocument[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
-
- // 다이얼로그가 열릴 때 첨부파일 목록 로드
- React.useEffect(() => {
- if (open) {
- loadDocuments()
- }
- }, [open, biddingId, companyId])
-
- const loadDocuments = async () => {
- setIsLoading(true)
- try {
- const docs = await getPreQuoteDocuments(biddingId, companyId)
- // Date를 string으로 변환
- const mappedDocs = docs.map(doc => ({
- ...doc,
- uploadedAt: doc.uploadedAt.toString(),
- uploadedBy: doc.uploadedBy || ''
- }))
- setDocuments(mappedDocs)
- } catch (error) {
- console.error('Failed to load documents:', error)
- toast({
- title: '오류',
- description: '첨부파일 목록을 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- } finally {
- setIsLoading(false)
- }
- }
-
- // 파일 다운로드
- const handleDownload = (document: UploadedDocument) => {
- startTransition(async () => {
- const result = await getPreQuoteDocumentForDownload(document.id, biddingId, companyId)
-
- if (result.success) {
- try {
- await downloadFile(result.document?.filePath, result.document?.originalFileName, {
- showToast: true
- })
- } catch (error) {
- toast({
- title: '다운로드 실패',
- description: '파일 다운로드에 실패했습니다.',
- variant: 'destructive',
- })
- }
- } else {
- toast({
- title: '다운로드 실패',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- // 파일 크기 포맷팅
- const formatFileSize = (bytes: number | null) => {
- if (!bytes) return '-'
- if (bytes === 0) return '0 Bytes'
- const k = 1024
- const sizes = ['Bytes', 'KB', 'MB', 'GB']
- const i = Math.floor(Math.log(bytes) / Math.log(k))
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- <span>협력업체 첨부파일</span>
- <span className="text-sm font-normal text-muted-foreground">
- - {companyName}
- </span>
- </DialogTitle>
- <DialogDescription>
- 협력업체가 제출한 견적 관련 첨부파일 목록입니다.
- </DialogDescription>
- </DialogHeader>
-
- {isLoading ? (
- <div className="flex items-center justify-center py-12">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
- <p className="text-muted-foreground">첨부파일 목록을 불러오는 중...</p>
- </div>
- </div>
- ) : documents.length > 0 ? (
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <Badge variant="secondary" className="text-sm">
- 총 {documents.length}개 파일
- </Badge>
- </div>
-
- <Table>
- <TableHeader>
- <TableRow>
- <TableHead>파일명</TableHead>
- <TableHead>크기</TableHead>
- <TableHead>업로드일</TableHead>
- <TableHead>작성자</TableHead>
- <TableHead className="w-24">작업</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {documents.map((doc) => (
- <TableRow key={doc.id}>
- <TableCell>
- <div className="flex items-center gap-2">
- <FileText className="w-4 h-4 text-gray-500" />
- <span className="truncate max-w-48" title={doc.originalFileName}>
- {doc.originalFileName}
- </span>
- </div>
- </TableCell>
- <TableCell className="text-sm text-gray-500">
- {formatFileSize(doc.fileSize)}
- </TableCell>
- <TableCell className="text-sm text-gray-500">
- <div className="flex items-center gap-1">
- <Calendar className="w-3 h-3" />
- {new Date(doc.uploadedAt).toLocaleDateString('ko-KR')}
- </div>
- </TableCell>
- <TableCell className="text-sm text-gray-500">
- <div className="flex items-center gap-1">
- <User className="w-3 h-3" />
- {doc.uploadedBy}
- </div>
- </TableCell>
- <TableCell>
- <Button
- variant="outline"
- size="sm"
- onClick={() => handleDownload(doc)}
- disabled={isPending}
- title="다운로드"
- >
- <Download className="w-3 h-3" />
- </Button>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
- ) : (
- <div className="text-center py-12 text-gray-500">
- <FileText className="w-12 h-12 mx-auto mb-4 opacity-50" />
- <p className="text-lg font-medium mb-2">첨부파일이 없습니다</p>
- <p className="text-sm">협력업체가 아직 첨부파일을 업로드하지 않았습니다.</p>
- </div>
- )}
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
deleted file mode 100644
index 91b80bd3..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-content.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Bidding } from '@/db/schema'
-import { QuotationDetails } from '@/lib/bidding/detail/service'
-import { getBiddingCompanies } from '../service'
-
-import { BiddingPreQuoteVendorTableContent } from './bidding-pre-quote-vendor-table'
-
-interface BiddingPreQuoteContentProps {
- bidding: Bidding
- quotationDetails: QuotationDetails | null
- biddingCompanies: any[]
- prItems: any[]
-}
-
-export function BiddingPreQuoteContent({
- bidding,
- quotationDetails,
- biddingCompanies: initialBiddingCompanies,
- prItems
-}: BiddingPreQuoteContentProps) {
- const [biddingCompanies, setBiddingCompanies] = React.useState(initialBiddingCompanies)
- const [refreshTrigger, setRefreshTrigger] = React.useState(0)
-
- const handleRefresh = React.useCallback(async () => {
- try {
- const result = await getBiddingCompanies(bidding.id)
- if (result.success && result.data) {
- setBiddingCompanies(result.data)
- }
- setRefreshTrigger(prev => prev + 1)
- } catch (error) {
- console.error('Failed to refresh bidding companies:', error)
- }
- }, [bidding.id])
-
- return (
- <div className="space-y-6">
- <BiddingPreQuoteVendorTableContent
- biddingId={bidding.id}
- bidding={bidding}
- biddingCompanies={biddingCompanies}
- onRefresh={handleRefresh}
- onOpenItemsDialog={() => {}}
- onOpenTargetPriceDialog={() => {}}
- onOpenSelectionReasonDialog={() => {}}
- />
- </div>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
deleted file mode 100644
index 3205df08..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-invitation-dialog.tsx
+++ /dev/null
@@ -1,770 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Badge } from '@/components/ui/badge'
-import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { sendPreQuoteInvitations, sendBiddingBasicContracts, getExistingBasicContractsForBidding } from '../service'
-import { getActiveContractTemplates } from '../../service'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { Mail, Building2, Calendar, FileText, CheckCircle, Info, RefreshCw } from 'lucide-react'
-import { Progress } from '@/components/ui/progress'
-import { Separator } from '@/components/ui/separator'
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { cn } from '@/lib/utils'
-
-interface BiddingPreQuoteInvitationDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- companies: BiddingCompany[]
- biddingId: number
- biddingTitle: string
- projectName?: string
- onSuccess: () => void
-}
-
-interface BasicContractTemplate {
- id: number
- templateName: string
- revision: number
- status: string
- filePath: string | null
- validityPeriod: number | null
- legalReviewRequired: boolean
- createdAt: Date | null
-}
-
-interface SelectedContract {
- templateId: number
- templateName: string
- contractType: string // templateName을 contractType으로 사용
- checked: boolean
-}
-
-// PDF 생성 유틸리티 함수
-const generateBasicContractPdf = async (
- template: BasicContractTemplate,
- vendorId: number
-): Promise<{ buffer: number[]; fileName: string }> => {
- try {
- // 1. 템플릿 데이터 준비 (서버 API 호출)
- const prepareResponse = await fetch("/api/contracts/prepare-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- templateName: template.templateName,
- vendorId,
- }),
- });
-
- if (!prepareResponse.ok) {
- throw new Error("템플릿 준비 실패");
- }
-
- const { template: preparedTemplate, templateData } = await prepareResponse.json();
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: preparedTemplate.filePath }),
- });
-
- const templateBlob = await templateResponse.blob();
- const templateFile = new window.File([templateBlob], "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- });
-
- // 3. PDFTron WebViewer로 PDF 변환
- const { default: WebViewer } = await import("@pdftron/webviewer");
-
- const tempDiv = document.createElement('div');
- tempDiv.style.display = 'none';
- document.body.appendChild(tempDiv);
-
- try {
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- );
-
- const { Core } = instance;
- const { createDocument } = Core;
-
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- });
-
- // 변수 치환 적용
- await templateDoc.applyTemplateValues(templateData);
-
- // PDF 변환
- const fileData = await templateDoc.getFileData();
- const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
-
- const fileName = `${template.templateName}_${Date.now()}.pdf`;
-
- return {
- buffer: Array.from(pdfBuffer), // Uint8Array를 일반 배열로 변환
- fileName
- };
-
- } finally {
- if (tempDiv.parentNode) {
- document.body.removeChild(tempDiv);
- }
- }
- } catch (error) {
- console.error(`기본계약 PDF 생성 실패 (${template.templateName}):`, error);
- throw error;
- }
-};
-
-export function BiddingPreQuoteInvitationDialog({
- open,
- onOpenChange,
- companies,
- biddingId,
- biddingTitle,
- projectName,
- onSuccess
-}: BiddingPreQuoteInvitationDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [selectedCompanyIds, setSelectedCompanyIds] = React.useState<number[]>([])
- const [preQuoteDeadline, setPreQuoteDeadline] = React.useState('')
- const [additionalMessage, setAdditionalMessage] = React.useState('')
-
- // 기본계약 관련 상태
- const [existingContracts, setExistingContracts] = React.useState<any[]>([])
- const [isGeneratingPdfs, setIsGeneratingPdfs] = React.useState(false)
- const [pdfGenerationProgress, setPdfGenerationProgress] = React.useState(0)
- const [currentGeneratingContract, setCurrentGeneratingContract] = React.useState('')
-
- // 기본계약서 템플릿 관련 상태
- const [availableTemplates, setAvailableTemplates] = React.useState<BasicContractTemplate[]>([])
- const [selectedContracts, setSelectedContracts] = React.useState<SelectedContract[]>([])
- const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
-
- // 초대 가능한 업체들 (pending 상태인 업체들)
- const invitableCompanies = React.useMemo(() => companies.filter(company =>
- company.invitationStatus === 'pending' && company.companyName
- ), [companies])
-
- // 다이얼로그가 열릴 때 기존 계약 조회 및 템플릿 로드
- React.useEffect(() => {
- if (open) {
- const fetchInitialData = async () => {
- setIsLoadingTemplates(true);
- try {
- const [contractsResult, templatesData] = await Promise.all([
- getExistingBasicContractsForBidding(biddingId),
- getActiveContractTemplates()
- ]);
-
- // 기존 계약 조회 - 서버 액션 사용
- const existingContractsResult = await getExistingBasicContractsForBidding(biddingId);
- setExistingContracts(existingContractsResult.success ? existingContractsResult.contracts || [] : []);
-
- // 템플릿 로드 (4개 타입만 필터링)
- // 4개 템플릿 타입만 필터링: 비밀, General, Project, 기술자료
- const allowedTemplateNames = ['비밀', 'General GTC', '기술', '기술자료'];
- const filteredTemplates = (templatesData.templates || []).filter((template: any) =>
- allowedTemplateNames.some(allowedName =>
- template.templateName.includes(allowedName) ||
- allowedName.includes(template.templateName)
- )
- );
- setAvailableTemplates(filteredTemplates as BasicContractTemplate[]);
- const initialSelected = filteredTemplates.map((template: any) => ({
- templateId: template.id,
- templateName: template.templateName,
- contractType: template.templateName,
- checked: false
- }));
- setSelectedContracts(initialSelected);
-
- } catch (error) {
- console.error('초기 데이터 로드 실패:', error);
- toast({
- title: '오류',
- description: '기본 정보를 불러오는 데 실패했습니다.',
- variant: 'destructive',
- });
- setExistingContracts([]);
- setAvailableTemplates([]);
- setSelectedContracts([]);
- } finally {
- setIsLoadingTemplates(false);
- }
- }
- fetchInitialData();
- }
- }, [open, biddingId, toast]);
-
- const handleSelectAll = (checked: boolean | 'indeterminate') => {
- if (checked) {
- // 기존 계약이 없는 업체만 선택
- const availableCompanies = invitableCompanies.filter(company =>
- !existingContracts.some(ec => ec.vendorId === company.companyId)
- )
- setSelectedCompanyIds(availableCompanies.map(company => company.id))
- } else {
- setSelectedCompanyIds([])
- }
- }
-
- const handleSelectCompany = (companyId: number, checked: boolean) => {
- const company = invitableCompanies.find(c => c.id === companyId)
- const hasExistingContract = company ? existingContracts.some(ec => ec.vendorId === company.companyId) : false
-
- if (hasExistingContract) {
- toast({
- title: '선택 불가',
- description: '이미 기본계약서를 받은 업체는 다시 선택할 수 없습니다.',
- variant: 'default',
- })
- return
- }
-
- if (checked) {
- setSelectedCompanyIds(prev => [...prev, companyId])
- } else {
- setSelectedCompanyIds(prev => prev.filter(id => id !== companyId))
- }
- }
-
- // 기본계약서 선택 토글
- const toggleContractSelection = (templateId: number) => {
- setSelectedContracts(prev =>
- prev.map(contract =>
- contract.templateId === templateId
- ? { ...contract, checked: !contract.checked }
- : contract
- )
- )
- }
-
- // 모든 기본계약서 선택/해제
- const toggleAllContractSelection = (checked: boolean | 'indeterminate') => {
- setSelectedContracts(prev =>
- prev.map(contract => ({ ...contract, checked: !!checked }))
- )
- }
-
- const handleSendInvitations = () => {
- if (selectedCompanyIds.length === 0) {
- toast({
- title: '알림',
- description: '초대를 발송할 업체를 선택해주세요.',
- variant: 'default',
- })
- return
- }
-
- const selectedContractTemplates = selectedContracts.filter(c => c.checked);
- const companiesForContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id));
-
- const vendorsToGenerateContracts = companiesForContracts.filter(company =>
- !existingContracts.some(ec =>
- ec.vendorId === company.companyId && ec.biddingCompanyId === company.id
- )
- );
-
- startTransition(async () => {
- try {
- // 1. 사전견적 초대 발송
- const invitationResponse = await sendPreQuoteInvitations(
- selectedCompanyIds,
- preQuoteDeadline || undefined
- )
-
- if (!invitationResponse.success) {
- toast({
- title: '초대 발송 실패',
- description: invitationResponse.error,
- variant: 'destructive',
- })
- return
- }
-
- // 2. 기본계약 발송 (선택된 템플릿과 업체가 있는 경우)
- let contractResponse: Awaited<ReturnType<typeof sendBiddingBasicContracts>> | null = null
- if (selectedContractTemplates.length > 0 && selectedCompanyIds.length > 0) {
- setIsGeneratingPdfs(true)
- setPdfGenerationProgress(0)
-
- const generatedPdfsMap = new Map<string, { buffer: number[], fileName: string }>()
-
- let generatedCount = 0;
- for (const vendor of vendorsToGenerateContracts) {
- for (const contract of selectedContractTemplates) {
- setCurrentGeneratingContract(`${vendor.companyName} - ${contract.templateName}`);
- const templateDetails = availableTemplates.find(t => t.id === contract.templateId);
-
- if (templateDetails) {
- const pdfData = await generateBasicContractPdf(templateDetails, vendor.companyId);
- // sendBiddingBasicContracts와 동일한 키 형식 사용
- let contractType = '';
- if (contract.templateName.includes('비밀')) {
- contractType = 'NDA';
- } else if (contract.templateName.includes('General GTC')) {
- contractType = 'General_GTC';
- } else if (contract.templateName.includes('기술') && !contract.templateName.includes('기술자료')) {
- contractType = 'Project_GTC';
- } else if (contract.templateName.includes('기술자료')) {
- contractType = '기술자료';
- }
- const key = `${vendor.companyId}_${contractType}_${contract.templateName}`;
- generatedPdfsMap.set(key, pdfData);
- }
- }
- generatedCount++;
- setPdfGenerationProgress((generatedCount / vendorsToGenerateContracts.length) * 100);
- }
-
- setIsGeneratingPdfs(false);
-
- const vendorData = companiesForContracts.map(company => {
- // 선택된 템플릿에 따라 contractRequirements 동적으로 설정
- const contractRequirements = {
- ndaYn: selectedContractTemplates.some(c => c.templateName.includes('비밀')),
- generalGtcYn: selectedContractTemplates.some(c => c.templateName.includes('General GTC')),
- projectGtcYn: selectedContractTemplates.some(c => c.templateName.includes('기술') && !c.templateName.includes('기술자료')),
- agreementYn: selectedContractTemplates.some(c => c.templateName.includes('기술자료'))
- };
-
- return {
- vendorId: company.companyId,
- vendorName: company.companyName || '',
- vendorCode: company.companyCode,
- vendorCountry: '대한민국',
- selectedMainEmail: company.contactEmail || '',
- contactPerson: company.contactPerson,
- contactEmail: company.contactEmail,
- biddingCompanyId: company.id,
- biddingId: biddingId,
- hasExistingContracts: existingContracts.some(ec =>
- ec.vendorId === company.companyId && ec.biddingCompanyId === company.id
- ),
- contractRequirements,
- additionalEmails: [],
- customEmails: []
- };
- });
-
- const pdfsArray = Array.from(generatedPdfsMap.entries()).map(([key, data]) => ({
- key,
- buffer: data.buffer,
- fileName: data.fileName,
- }));
-
- console.log("Calling sendBiddingBasicContracts with biddingId:", biddingId);
- console.log("vendorData:", vendorData.map(v => ({ vendorId: v.vendorId, biddingCompanyId: v.biddingCompanyId, biddingId: v.biddingId })));
-
- contractResponse = await sendBiddingBasicContracts(
- biddingId,
- vendorData,
- pdfsArray,
- additionalMessage
- );
- }
-
- let successMessage = '사전견적 초대가 성공적으로 발송되었습니다.';
- if (contractResponse && contractResponse.success) {
- successMessage += `\n${contractResponse.message}`;
- }
-
- toast({
- title: '성공',
- description: successMessage,
- })
-
- // 상태 초기화
- setSelectedCompanyIds([]);
- setPreQuoteDeadline('');
- setAdditionalMessage('');
- setExistingContracts([]);
- setIsGeneratingPdfs(false);
- setPdfGenerationProgress(0);
- setCurrentGeneratingContract('');
- setSelectedContracts(prev => prev.map(c => ({ ...c, checked: false })));
-
- onOpenChange(false);
- onSuccess();
-
- } catch (error) {
- console.error('발송 실패:', error);
- toast({
- title: '오류',
- description: '발송 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
- variant: 'destructive',
- });
- setIsGeneratingPdfs(false);
- }
- })
- }
-
- const handleOpenChange = (open: boolean) => {
- onOpenChange(open)
- if (!open) {
- setSelectedCompanyIds([])
- setPreQuoteDeadline('')
- setAdditionalMessage('')
- setExistingContracts([])
- setIsGeneratingPdfs(false)
- setPdfGenerationProgress(0)
- setCurrentGeneratingContract('')
- setSelectedContracts([])
- }
- }
-
- const selectedContractCount = selectedContracts.filter(c => c.checked).length;
- const selectedCompanyCount = selectedCompanyIds.length;
- const companiesToReceiveContracts = invitableCompanies.filter(company => selectedCompanyIds.includes(company.id));
-
- // 기존 계약이 없는 업체들만 계산
- const availableCompanies = invitableCompanies.filter(company =>
- !existingContracts.some(ec => ec.vendorId === company.companyId)
- );
- const selectedAvailableCompanyCount = selectedCompanyIds.filter(id =>
- availableCompanies.some(company => company.id === id)
- ).length;
-
- // 선택된 업체들 중 기존 계약이 있는 업체들
- const selectedCompaniesWithExistingContracts = invitableCompanies.filter(company =>
- selectedCompanyIds.includes(company.id) &&
- existingContracts.some(ec => ec.vendorId === company.companyId)
- );
-
- return (
- <Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <Mail className="w-5 h-5" />
- 사전견적 초대 및 기본계약 발송
- </DialogTitle>
- <DialogDescription>
- 선택한 업체들에게 사전견적 요청과 기본계약서를 발송합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="flex-1 overflow-y-auto px-1" style={{ maxHeight: 'calc(70vh - 200px)' }}>
- <div className="space-y-6 pr-4">
- {/* 견적 마감일 설정 */}
- <div className="mb-6 p-4 border rounded-lg bg-muted/30">
- <Label htmlFor="preQuoteDeadline" className="text-sm font-medium mb-2 flex items-center gap-2">
- <Calendar className="w-4 h-4" />
- 견적 마감일
- </Label>
- <Input
- id="preQuoteDeadline"
- type="datetime-local"
- value={preQuoteDeadline}
- onChange={(e) => setPreQuoteDeadline(e.target.value)}
- className="w-full"
- />
- </div>
-
- {/* 기존 계약 정보 알림 */}
- {existingContracts.length > 0 && (
- <Alert className="border-orange-500 bg-orange-50">
- <Info className="h-4 w-4 text-orange-600" />
- <AlertTitle className="text-orange-800">기존 계약 정보</AlertTitle>
- <AlertDescription className="text-orange-700">
- 이미 기본계약을 받은 업체가 있습니다.
- 해당 업체들은 초대 대상에서 제외되며, 계약서 재생성도 건너뜁니다.
- </AlertDescription>
- </Alert>
- )}
-
- {/* 업체 선택 섹션 */}
- <Card className="border-2 border-dashed">
- <CardHeader className="pb-3">
- <CardTitle className="flex items-center gap-2 text-base">
- <Building2 className="h-5 w-5 text-green-600" />
- 초대 대상 업체
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {invitableCompanies.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- 초대 가능한 업체가 없습니다.
- </div>
- ) : (
- <>
- <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
- <div className="flex items-center gap-2">
- <Checkbox
- id="select-all-companies"
- checked={selectedAvailableCompanyCount === availableCompanies.length && availableCompanies.length > 0}
- onCheckedChange={handleSelectAll}
- />
- <Label htmlFor="select-all-companies" className="font-medium">
- 전체 선택 ({availableCompanies.length}개 업체)
- </Label>
- </div>
- <Badge variant="outline">
- {selectedCompanyCount}개 선택됨
- </Badge>
- </div>
-
- <div className="space-y-3 max-h-80 overflow-y-auto">
- {invitableCompanies.map((company) => {
- const hasExistingContract = existingContracts.some(ec => ec.vendorId === company.companyId);
- return (
- <div key={company.id} className={cn("flex items-center space-x-3 p-3 border rounded-lg transition-colors",
- selectedCompanyIds.includes(company.id) && !hasExistingContract && "border-green-500 bg-green-50",
- hasExistingContract && "border-orange-500 bg-orange-50 opacity-75"
- )}>
- <Checkbox
- id={`company-${company.id}`}
- checked={selectedCompanyIds.includes(company.id)}
- disabled={hasExistingContract}
- onCheckedChange={(checked) => handleSelectCompany(company.id, !!checked)}
- />
- <div className="flex-1">
- <div className="flex items-center gap-2">
- <span className={cn("font-medium", hasExistingContract && "text-muted-foreground")}>
- {company.companyName}
- </span>
- <Badge variant="outline" className="text-xs">
- {company.companyCode}
- </Badge>
- {hasExistingContract && (
- <Badge variant="secondary" className="text-xs">
- <CheckCircle className="h-3 w-3 mr-1" />
- 계약 체결됨
- </Badge>
- )}
- </div>
- {hasExistingContract && (
- <p className="text-xs text-orange-600 mt-1">
- 이미 기본계약서를 받은 업체입니다. 선택에서 제외됩니다.
- </p>
- )}
- </div>
- </div>
- )
- })}
- </div>
- </>
- )}
- </CardContent>
- </Card>
-
- {/* 선택된 업체 중 기존 계약이 있는 경우 경고 */}
- {selectedCompaniesWithExistingContracts.length > 0 && (
- <Alert className="border-red-500 bg-red-50">
- <Info className="h-4 w-4 text-red-600" />
- <AlertTitle className="text-red-800">선택한 업체 중 제외될 업체</AlertTitle>
- <AlertDescription className="text-red-700">
- 선택한 {selectedCompaniesWithExistingContracts.length}개 업체가 이미 기본계약서를 받았습니다.
- 이 업체들은 초대 발송 및 계약서 생성에서 제외됩니다.
- <br />
- <strong>실제 발송 대상: {selectedCompanyCount - selectedCompaniesWithExistingContracts.length}개 업체</strong>
- </AlertDescription>
- </Alert>
- )}
-
- {/* 기본계약서 선택 섹션 */}
- <Separator />
- <Card className="border-2 border-dashed">
- <CardHeader className="pb-3">
- <CardTitle className="flex items-center gap-2 text-base">
- <FileText className="h-5 w-5 text-blue-600" />
- 기본계약서 선택 (선택된 업체에만 발송)
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoadingTemplates ? (
- <div className="text-center py-6">
- <RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-blue-600" />
- <p className="text-sm text-muted-foreground">기본계약서 템플릿을 불러오는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {selectedCompanyCount === 0 && (
- <Alert className="border-red-500 bg-red-50">
- <Info className="h-4 w-4 text-red-600" />
- <AlertTitle className="text-red-800">알림</AlertTitle>
- <AlertDescription className="text-red-700">
- 기본계약서를 발송할 업체를 먼저 선택해주세요.
- </AlertDescription>
- </Alert>
- )}
- {availableTemplates.length === 0 ? (
- <div className="text-center py-8 text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>사용 가능한 기본계약서 템플릿이 없습니다.</p>
- </div>
- ) : (
- <>
- <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
- <div className="flex items-center gap-2">
- <Checkbox
- id="select-all-contracts"
- checked={selectedContracts.length > 0 && selectedContracts.every(c => c.checked)}
- onCheckedChange={toggleAllContractSelection}
- />
- <Label htmlFor="select-all-contracts" className="font-medium">
- 전체 선택 ({availableTemplates.length}개 템플릿)
- </Label>
- </div>
- <Badge variant="outline">
- {selectedContractCount}개 선택됨
- </Badge>
- </div>
- <div className="grid gap-3 max-h-60 overflow-y-auto">
- {selectedContracts.map((contract) => (
- <div
- key={contract.templateId}
- className={cn(
- "flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer",
- contract.checked && "border-blue-500 bg-blue-50"
- )}
- onClick={() => toggleContractSelection(contract.templateId)}
- >
- <div className="flex items-center gap-3">
- <Checkbox
- id={`contract-${contract.templateId}`}
- checked={contract.checked}
- onCheckedChange={() => toggleContractSelection(contract.templateId)}
- />
- <div className="flex-1">
- <Label
- htmlFor={`contract-${contract.templateId}`}
- className="font-medium cursor-pointer"
- >
- {contract.templateName}
- </Label>
- <p className="text-xs text-muted-foreground mt-1">
- {contract.contractType}
- </p>
- </div>
- </div>
- </div>
- ))}
- </div>
- </>
- )}
- {selectedContractCount > 0 && (
- <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg">
- <div className="flex items-center gap-2 mb-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium text-green-900 text-sm">
- 선택된 기본계약서 ({selectedContractCount}개)
- </span>
- </div>
- <ul className="space-y-1 text-xs text-green-800 list-disc list-inside">
- {selectedContracts.filter(c => c.checked).map((contract) => (
- <li key={contract.templateId}>
- {contract.templateName}
- </li>
- ))}
- </ul>
- </div>
- )}
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 추가 메시지 */}
- <div className="space-y-2">
- <Label htmlFor="contractMessage" className="text-sm font-medium">
- 계약서 추가 메시지 (선택사항)
- </Label>
- <textarea
- id="contractMessage"
- className="w-full min-h-[60px] p-3 text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary"
- placeholder="기본계약서와 함께 보낼 추가 메시지를 입력하세요..."
- value={additionalMessage}
- onChange={(e) => setAdditionalMessage(e.target.value)}
- />
- </div>
-
- {/* PDF 생성 진행 상황 */}
- {isGeneratingPdfs && (
- <Alert className="border-blue-500 bg-blue-50">
- <div className="space-y-3">
- <div className="flex items-center gap-2">
- <RefreshCw className="h-4 w-4 animate-spin text-blue-600" />
- <AlertTitle className="text-blue-800">기본계약서 생성 중</AlertTitle>
- </div>
- <AlertDescription>
- <div className="space-y-2">
- <p className="text-sm text-blue-700">{currentGeneratingContract}</p>
- <Progress value={pdfGenerationProgress} className="h-2" />
- <p className="text-xs text-blue-600">
- {Math.round(pdfGenerationProgress)}% 완료
- </p>
- </div>
- </AlertDescription>
- </div>
- </Alert>
- )}
- </div>
- </div>
-
- <DialogFooter className="flex-col sm:flex-row-reverse sm:justify-between items-center px-4 pt-4">
- <div className="flex gap-2 w-full sm:w-auto">
- <Button variant="outline" onClick={() => handleOpenChange(false)} className="w-full sm:w-auto">
- 취소
- </Button>
- <Button
- onClick={handleSendInvitations}
- disabled={isPending || selectedCompanyCount === 0 || isGeneratingPdfs}
- className="w-full sm:w-auto"
- >
- {isPending ? (
- <>
- <RefreshCw className="w-4 h-4 mr-2 animate-spin" />
- 발송 중...
- </>
- ) : (
- <>
- <Mail className="w-4 h-4 mr-2" />
- 초대 발송 및 계약서 생성
- </>
- )}
- </Button>
- </div>
- {/* {(selectedCompanyCount > 0 || selectedContractCount > 0) && (
- <div className="mt-4 sm:mt-0 text-sm text-muted-foreground">
- {selectedCompanyCount > 0 && (
- <p>
- <strong>{selectedCompanyCount}개 업체</strong>에 초대를 발송합니다.
- </p>
- )}
- {selectedContractCount > 0 && selectedCompanyCount > 0 && (
- <p>
- 이 중 <strong>{companiesToReceiveContracts.length}개 업체</strong>에 <strong>{selectedContractCount}개</strong>의 기본계약서를 발송합니다.
- </p>
- )}
- </div>
- )} */}
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
deleted file mode 100644
index f676709c..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-item-details-dialog.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { PrItemsPricingTable } from '../../vendor/components/pr-items-pricing-table'
-import { getSavedPrItemQuotations } from '../service'
-
-interface PrItem {
- id: number
- itemNumber: string | null
- prNumber: string | null
- itemInfo: string | null
- materialDescription: string | null
- quantity: string | null
- quantityUnit: string | null
- totalWeight: string | null
- weightUnit: string | null
- currency: string | null
- requestedDeliveryDate: string | null
- hasSpecDocument: boolean | null
-}
-
-interface PrItemQuotation {
- prItemId: number
- bidUnitPrice: number
- bidAmount: number
- proposedDeliveryDate?: string
- technicalSpecification?: string
-}
-
-interface BiddingPreQuoteItemDetailsDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- biddingId: number
- biddingCompanyId: number
- companyName: string
- prItems: PrItem[]
- currency?: string
-}
-
-export function BiddingPreQuoteItemDetailsDialog({
- open,
- onOpenChange,
- biddingId,
- biddingCompanyId,
- companyName,
- prItems,
- currency = 'KRW'
-}: BiddingPreQuoteItemDetailsDialogProps) {
- const [prItemQuotations, setPrItemQuotations] = React.useState<PrItemQuotation[]>([])
- const [isLoading, setIsLoading] = React.useState(false)
-
- // 다이얼로그가 열릴 때 저장된 품목별 견적 데이터 로드
- React.useEffect(() => {
- if (open && biddingCompanyId) {
- loadSavedQuotations()
- }
- }, [open, biddingCompanyId])
-
- const loadSavedQuotations = async () => {
- setIsLoading(true)
- try {
- console.log('Loading saved quotations for biddingCompanyId:', biddingCompanyId)
- const savedQuotations = await getSavedPrItemQuotations(biddingCompanyId)
- console.log('Loaded saved quotations:', savedQuotations)
- setPrItemQuotations(savedQuotations)
- } catch (error) {
- console.error('Failed to load saved quotations:', error)
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleQuotationsChange = (quotations: PrItemQuotation[]) => {
- // ReadOnly 모드이므로 변경사항을 저장하지 않음
- console.log('Quotations changed (readonly):', quotations)
- }
-
- const handleTotalAmountChange = (total: number) => {
- // ReadOnly 모드이므로 총 금액 변경을 처리하지 않음
- console.log('Total amount changed (readonly):', total)
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <span>품목별 견적 상세</span>
- <span className="text-sm font-normal text-muted-foreground">
- - {companyName}
- </span>
- </DialogTitle>
- <DialogDescription>
- 협력업체가 제출한 품목별 견적 상세 정보입니다.
- </DialogDescription>
- </DialogHeader>
-
- {isLoading ? (
- <div className="flex items-center justify-center py-12">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
- <p className="text-muted-foreground">견적 정보를 불러오는 중...</p>
- </div>
- </div>
- ) : (
- <PrItemsPricingTable
- prItems={prItems}
- initialQuotations={prItemQuotations}
- currency={currency}
- onQuotationsChange={handleQuotationsChange}
- onTotalAmountChange={handleTotalAmountChange}
- readOnly={true}
- />
- )}
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx
deleted file mode 100644
index e0194f2a..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-selection-dialog.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { updatePreQuoteSelection } from '../service'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Badge } from '@/components/ui/badge'
-import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-
-interface BiddingPreQuoteSelectionDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedCompanies: BiddingCompany[]
- onSuccess: () => void
-}
-
-export function BiddingPreQuoteSelectionDialog({
- open,
- onOpenChange,
- selectedCompanies,
- onSuccess
-}: BiddingPreQuoteSelectionDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- // 선택된 업체들의 현재 상태 분석 (선정만 가능)
- const unselectedCompanies = selectedCompanies.filter(c => !c.isPreQuoteSelected)
- const hasQuotationCompanies = selectedCompanies.filter(c => c.preQuoteAmount && Number(c.preQuoteAmount) > 0)
-
- const handleConfirm = () => {
- const companyIds = selectedCompanies.map(c => c.id)
- const isSelected = true // 항상 선정으로 고정
-
- startTransition(async () => {
- const result = await updatePreQuoteSelection(
- companyIds,
- isSelected
- )
-
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
- onSuccess()
- onOpenChange(false)
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const getActionIcon = (isSelected: boolean) => {
- return isSelected ?
- <CheckCircle className="h-4 w-4 text-muted-foreground" /> :
- <CheckCircle className="h-4 w-4 text-green-600" />
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <AlertCircle className="h-5 w-5 text-amber-500" />
- 본입찰 선정 상태 변경
- </DialogTitle>
- <DialogDescription>
- 선택된 {selectedCompanies.length}개 업체의 본입찰 선정 상태를 변경합니다.
- </DialogDescription>
- </DialogHeader>
-
- <div className="space-y-4">
- {/* 견적 제출 여부 안내 */}
- {hasQuotationCompanies.length !== selectedCompanies.length && (
- <div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
- <div className="flex items-center gap-2 text-amber-800">
- <AlertCircle className="h-4 w-4" />
- <span className="text-sm font-medium">알림</span>
- </div>
- <p className="text-sm text-amber-700 mt-1">
- 사전견적을 제출하지 않은 업체도 포함되어 있습니다.
- 견적 미제출 업체도 본입찰에 참여시키시겠습니까?
- </p>
- </div>
- )}
-
- {/* 업체 목록 */}
- <div className="border rounded-lg">
- <div className="p-3 bg-muted/50 border-b">
- <h4 className="font-medium">대상 업체 목록</h4>
- </div>
- <div className="max-h-64 overflow-y-auto">
- {selectedCompanies.map((company) => (
- <div key={company.id} className="flex items-center justify-between p-3 border-b last:border-b-0">
- <div className="flex items-center gap-3">
- {getActionIcon(company.isPreQuoteSelected)}
- <div>
- <div className="font-medium">{company.companyName}</div>
- <div className="text-sm text-muted-foreground">{company.companyCode}</div>
- </div>
- </div>
- <div className="flex items-center gap-2">
- <Badge variant={company.isPreQuoteSelected ? 'default' : 'secondary'}>
- {company.isPreQuoteSelected ? '현재 선정' : '현재 미선정'}
- </Badge>
- {company.preQuoteAmount && Number(company.preQuoteAmount) > 0 ? (
- <Badge variant="outline" className="text-green-600">
- 견적 제출
- </Badge>
- ) : (
- <Badge variant="outline" className="text-muted-foreground">
- 견적 미제출
- </Badge>
- )}
- </div>
- </div>
- ))}
- </div>
- </div>
-
- {/* 결과 요약 */}
- <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
- <h5 className="font-medium text-blue-900 mb-2">변경 결과</h5>
- <div className="text-sm text-blue-800">
- <p>• {unselectedCompanies.length}개 업체가 본입찰 대상으로 <span className="font-medium text-green-600">선정</span>됩니다.</p>
- {selectedCompanies.length > unselectedCompanies.length && (
- <p>• {selectedCompanies.length - unselectedCompanies.length}개 업체는 이미 선정 상태이므로 변경되지 않습니다.</p>
- )}
- </div>
- </div>
- </div>
-
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 취소
- </Button>
- <Button onClick={handleConfirm} disabled={isPending}>
- {isPending ? '처리 중...' : '확인'}
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
deleted file mode 100644
index 3266a568..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-columns.tsx
+++ /dev/null
@@ -1,398 +0,0 @@
-"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 {
- MoreHorizontal, Edit, Trash2, Paperclip
-} from "lucide-react"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu"
-
-// bidding_companies 테이블 타입 정의 (company_condition_responses와 join)
-export interface BiddingCompany {
- id: number
- biddingId: number
- companyId: number
- invitationStatus: 'pending' | 'sent' | 'accepted' | 'declined' | 'submitted'
- invitedAt: Date | null
- respondedAt: Date | null
- preQuoteAmount: string | null
- preQuoteSubmittedAt: Date | null
- preQuoteDeadline: Date | null
- isPreQuoteSelected: boolean
- isPreQuoteParticipated: boolean | null
- isAttendingMeeting: boolean | null
- notes: string | null
- contactPerson: string | null
- contactEmail: string | null
- contactPhone: string | null
- createdAt: Date
- updatedAt: Date
-
- // company_condition_responses 필드들
- paymentTermsResponse: string | null
- taxConditionsResponse: string | null
- proposedContractDeliveryDate: string | null
- priceAdjustmentResponse: boolean | null
- isInitialResponse: boolean | null
- incotermsResponse: string | null
- proposedShippingPort: string | null
- proposedDestinationPort: string | null
- sparePartResponse: string | null
- additionalProposals: string | null
-
- // 조인된 업체 정보
- companyName?: string
- companyCode?: string
-}
-
-interface GetBiddingCompanyColumnsProps {
- onEdit: (company: BiddingCompany) => void
- onDelete: (company: BiddingCompany) => void
- onViewPriceAdjustment?: (company: BiddingCompany) => void
- onViewItemDetails?: (company: BiddingCompany) => void
- onViewAttachments?: (company: BiddingCompany) => void
-}
-
-export function getBiddingPreQuoteVendorColumns({
- onEdit,
- onDelete,
- onViewPriceAdjustment,
- onViewItemDetails,
- onViewAttachments
-}: GetBiddingCompanyColumnsProps): ColumnDef<BiddingCompany>[] {
- return [
- {
- id: 'select',
- header: ({ table }) => (
- <Checkbox
- checked={table.getIsAllPageRowsSelected()}
- onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
- aria-label="모두 선택"
- />
- ),
- cell: ({ row }) => (
- <Checkbox
- checked={row.getIsSelected()}
- onCheckedChange={(value) => row.toggleSelected(!!value)}
- aria-label="행 선택"
- />
- ),
- enableSorting: false,
- enableHiding: false,
- },
- {
- accessorKey: 'companyName',
- header: '업체명',
- cell: ({ row }) => (
- <div className="font-medium">{row.original.companyName || '-'}</div>
- ),
- },
- {
- accessorKey: 'companyCode',
- header: '업체코드',
- cell: ({ row }) => (
- <div className="font-mono text-sm">{row.original.companyCode || '-'}</div>
- ),
- },
- {
- accessorKey: 'invitationStatus',
- header: '초대 상태',
- cell: ({ row }) => {
- const status = row.original.invitationStatus
- let variant: any
- let label: string
-
- if (status === 'accepted') {
- variant = 'default'
- label = '수락'
- } else if (status === 'declined') {
- variant = 'destructive'
- label = '거절'
- } else if (status === 'pending') {
- variant = 'outline'
- label = '대기중'
- } else if (status === 'sent') {
- variant = 'outline'
- label = '요청됨'
- } else if (status === 'submitted') {
- variant = 'outline'
- label = '제출됨'
- } else {
- variant = 'outline'
- label = status || '-'
- }
-
- return <Badge variant={variant}>{label}</Badge>
- },
- },
- {
- accessorKey: 'preQuoteAmount',
- header: '사전견적금액',
- cell: ({ row }) => {
- const hasAmount = row.original.preQuoteAmount && Number(row.original.preQuoteAmount) > 0
- return (
- <div className="text-right font-mono">
- {hasAmount ? (
- <button
- onClick={() => onViewItemDetails?.(row.original)}
- className="text-primary hover:text-primary/80 hover:underline cursor-pointer"
- title="품목별 견적 상세 보기"
- >
- {Number(row.original.preQuoteAmount).toLocaleString()} KRW
- </button>
- ) : (
- <span className="text-muted-foreground">-</span>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'preQuoteSubmittedAt',
- header: '사전견적 제출일',
- cell: ({ row }) => (
- <div className="text-sm">
- {row.original.preQuoteSubmittedAt ? new Date(row.original.preQuoteSubmittedAt).toLocaleDateString('ko-KR') : '-'}
- </div>
- ),
- },
- {
- accessorKey: 'preQuoteDeadline',
- header: '사전견적 마감일',
- cell: ({ row }) => {
- const deadline = row.original.preQuoteDeadline
- if (!deadline) {
- return <div className="text-muted-foreground text-sm">-</div>
- }
-
- const now = new Date()
- const deadlineDate = new Date(deadline)
- const isExpired = deadlineDate < now
-
- return (
- <div className={`text-sm ${isExpired ? 'text-red-600' : ''}`}>
- <div>{deadlineDate.toLocaleDateString('ko-KR')}</div>
- {isExpired && (
- <Badge variant="destructive" className="text-xs mt-1">
- 마감
- </Badge>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'attachments',
- header: '첨부파일',
- cell: ({ row }) => {
- const hasAttachments = row.original.preQuoteSubmittedAt // 제출된 경우에만 첨부파일이 있을 수 있음
- return (
- <div className="text-center">
- {hasAttachments ? (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => onViewAttachments?.(row.original)}
- className="h-8 w-8 p-0"
- title="첨부파일 보기"
- >
- <Paperclip className="h-4 w-4" />
- </Button>
- ) : (
- <span className="text-muted-foreground text-sm">-</span>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'isPreQuoteParticipated',
- header: '사전견적 참여의사',
- cell: ({ row }) => {
- const participated = row.original.isPreQuoteParticipated
- if (participated === null) {
- return <Badge variant="outline">미결정</Badge>
- }
- return (
- <Badge variant={participated ? 'default' : 'destructive'}>
- {participated ? '참여' : '미참여'}
- </Badge>
- )
- },
- },
- {
- accessorKey: 'isPreQuoteSelected',
- header: '본입찰 선정',
- cell: ({ row }) => (
- <Badge variant={row.original.isPreQuoteSelected ? 'default' : 'secondary'}>
- {row.original.isPreQuoteSelected ? '선정' : '미선정'}
- </Badge>
- ),
- },
- {
- accessorKey: 'isAttendingMeeting',
- header: '사양설명회 참석',
- cell: ({ row }) => {
- const isAttending = row.original.isAttendingMeeting
- if (isAttending === null) return <div className="text-sm">-</div>
- return (
- <Badge variant={isAttending ? 'default' : 'secondary'}>
- {isAttending ? '참석' : '불참석'}
- </Badge>
- )
- },
- },
- {
- accessorKey: 'paymentTermsResponse',
- header: '지급조건',
- cell: ({ row }) => (
- <div className="text-sm max-w-32 truncate" title={row.original.paymentTermsResponse || ''}>
- {row.original.paymentTermsResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'taxConditionsResponse',
- header: '세금조건',
- cell: ({ row }) => (
- <div className="text-sm max-w-32 truncate" title={row.original.taxConditionsResponse || ''}>
- {row.original.taxConditionsResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'incotermsResponse',
- header: '운송조건',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.incotermsResponse || ''}>
- {row.original.incotermsResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'isInitialResponse',
- header: '초도여부',
- cell: ({ row }) => {
- const isInitial = row.original.isInitialResponse
- if (isInitial === null) return <div className="text-sm">-</div>
- return (
- <Badge variant={isInitial ? 'default' : 'secondary'}>
- {isInitial ? 'Y' : 'N'}
- </Badge>
- )
- },
- },
- {
- accessorKey: 'priceAdjustmentResponse',
- header: '연동제',
- cell: ({ row }) => {
- const hasPriceAdjustment = row.original.priceAdjustmentResponse
- if (hasPriceAdjustment === null) return <div className="text-sm">-</div>
- return (
- <div className="flex items-center gap-2">
- <Badge variant={hasPriceAdjustment ? 'default' : 'secondary'}>
- {hasPriceAdjustment ? '적용' : '미적용'}
- </Badge>
- {hasPriceAdjustment && onViewPriceAdjustment && (
- <Button
- variant="ghost"
- size="sm"
- onClick={() => onViewPriceAdjustment(row.original)}
- className="h-6 px-2 text-xs"
- >
- 상세
- </Button>
- )}
- </div>
- )
- },
- },
- {
- accessorKey: 'proposedContractDeliveryDate',
- header: '제안납기일',
- cell: ({ row }) => (
- <div className="text-sm">
- {row.original.proposedContractDeliveryDate ?
- new Date(row.original.proposedContractDeliveryDate).toLocaleDateString('ko-KR') : '-'}
- </div>
- ),
- },
- {
- accessorKey: 'proposedShippingPort',
- header: '제안선적지',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.proposedShippingPort || ''}>
- {row.original.proposedShippingPort || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'proposedDestinationPort',
- header: '제안하역지',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.proposedDestinationPort || ''}>
- {row.original.proposedDestinationPort || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'sparePartResponse',
- header: '스페어파트',
- cell: ({ row }) => (
- <div className="text-sm max-w-24 truncate" title={row.original.sparePartResponse || ''}>
- {row.original.sparePartResponse || '-'}
- </div>
- ),
- },
- {
- accessorKey: 'additionalProposals',
- header: '추가제안',
- cell: ({ row }) => (
- <div className="text-sm max-w-32 truncate" title={row.original.additionalProposals || ''}>
- {row.original.additionalProposals || '-'}
- </div>
- ),
- },
- {
- id: 'actions',
- header: '액션',
- cell: ({ row }) => {
- const company = row.original
-
- return (
- <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={() => onEdit(company)}>
- <Edit className="mr-2 h-4 w-4" />
- 수정
- </DropdownMenuItem> */}
- <DropdownMenuItem
- onClick={() => onDelete(company)}
- className="text-destructive"
- >
- <Trash2 className="mr-2 h-4 w-4" />
- 삭제
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )
- },
- },
- ]
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
deleted file mode 100644
index bd078192..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx
+++ /dev/null
@@ -1,311 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from '@/components/ui/command'
-import {
- Popover,
- PopoverContent,
- PopoverTrigger
-} from '@/components/ui/popover'
-import { Check, ChevronsUpDown, Loader2, X, Plus, Search } from 'lucide-react'
-import { cn } from '@/lib/utils'
-import { createBiddingCompany } from '@/lib/bidding/pre-quote/service'
-import { searchVendorsForBidding } from '@/lib/bidding/service'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { Badge } from '@/components/ui/badge'
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
-import { ScrollArea } from '@/components/ui/scroll-area'
-import { Alert, AlertDescription } from '@/components/ui/alert'
-import { Info } from 'lucide-react'
-
-interface BiddingPreQuoteVendorCreateDialogProps {
- biddingId: number
- open: boolean
- onOpenChange: (open: boolean) => void
- onSuccess: () => void
-}
-
-interface Vendor {
- id: number
- vendorName: string
- vendorCode: string
- status: string
-}
-
-export function BiddingPreQuoteVendorCreateDialog({
- biddingId,
- open,
- onOpenChange,
- onSuccess
-}: BiddingPreQuoteVendorCreateDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
-
- // Vendor 검색 상태
- const [vendorList, setVendorList] = React.useState<Vendor[]>([])
- const [selectedVendors, setSelectedVendors] = React.useState<Vendor[]>([])
- const [vendorOpen, setVendorOpen] = React.useState(false)
-
-
- // 벤더 로드
- const loadVendors = React.useCallback(async () => {
- try {
- const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드
- setVendorList(result || [])
- } catch (error) {
- console.error('Failed to load vendors:', error)
- toast({
- title: '오류',
- description: '벤더 목록을 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- setVendorList([])
- }
- }, [biddingId])
-
- React.useEffect(() => {
- if (open) {
- loadVendors()
- }
- }, [open, loadVendors])
-
- // 초기화
- React.useEffect(() => {
- if (!open) {
- setSelectedVendors([])
- }
- }, [open])
-
- // 벤더 추가
- const handleAddVendor = (vendor: Vendor) => {
- if (!selectedVendors.find(v => v.id === vendor.id)) {
- setSelectedVendors([...selectedVendors, vendor])
- }
- setVendorOpen(false)
- }
-
- // 벤더 제거
- const handleRemoveVendor = (vendorId: number) => {
- setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId))
- }
-
- // 이미 선택된 벤더인지 확인
- const isVendorSelected = (vendorId: number) => {
- return selectedVendors.some(v => v.id === vendorId)
- }
-
- const handleCreate = () => {
- if (selectedVendors.length === 0) {
- toast({
- title: '오류',
- description: '업체를 선택해주세요.',
- variant: 'destructive',
- })
- return
- }
-
- startTransition(async () => {
- let successCount = 0
- let errorMessages: string[] = []
-
- for (const vendor of selectedVendors) {
- try {
- const response = await createBiddingCompany({
- biddingId,
- companyId: vendor.id,
- })
-
- if (response.success) {
- successCount++
- } else {
- errorMessages.push(`${vendor.vendorName}: ${response.error}`)
- }
- } catch (error) {
- errorMessages.push(`${vendor.vendorName}: 처리 중 오류가 발생했습니다.`)
- }
- }
-
- if (successCount > 0) {
- toast({
- title: '성공',
- description: `${successCount}개의 업체가 성공적으로 추가되었습니다.${errorMessages.length > 0 ? ` ${errorMessages.length}개는 실패했습니다.` : ''}`,
- })
- onOpenChange(false)
- resetForm()
- onSuccess()
- }
-
- if (errorMessages.length > 0 && successCount === 0) {
- toast({
- title: '오류',
- description: `업체 추가에 실패했습니다: ${errorMessages.join(', ')}`,
- variant: 'destructive',
- })
- }
- })
- }
-
- const resetForm = () => {
- setSelectedVendors([])
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-4xl max-h-[90vh] p-0 flex flex-col">
- {/* 헤더 */}
- <DialogHeader className="p-6 pb-0">
- <DialogTitle>사전견적 업체 추가</DialogTitle>
- <DialogDescription>
- 견적 요청을 보낼 업체를 선택하세요. 여러 개 선택 가능합니다.
- </DialogDescription>
- </DialogHeader>
-
- {/* 메인 컨텐츠 */}
- <div className="flex-1 px-6 py-4 overflow-y-auto">
- <div className="space-y-6">
- {/* 업체 선택 카드 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">업체 선택</CardTitle>
- <CardDescription>
- 사전견적을 발송할 업체를 선택하세요.
- </CardDescription>
- </CardHeader>
- <CardContent>
- <div className="space-y-4">
- {/* 업체 추가 버튼 */}
- <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={vendorOpen}
- className="w-full justify-between"
- disabled={vendorList.length === 0}
- >
- <span className="flex items-center gap-2">
- <Plus className="h-4 w-4" />
- 업체 선택하기
- </span>
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[500px] p-0" align="start">
- <Command>
- <CommandInput placeholder="업체명 또는 코드로 검색..." />
- <CommandList>
- <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
- <CommandGroup>
- {vendorList
- .filter(vendor => !isVendorSelected(vendor.id))
- .map((vendor) => (
- <CommandItem
- key={vendor.id}
- value={`${vendor.vendorCode} ${vendor.vendorName}`}
- onSelect={() => handleAddVendor(vendor)}
- >
- <div className="flex items-center gap-2 w-full">
- <Badge variant="outline" className="shrink-0">
- {vendor.vendorCode}
- </Badge>
- <span className="truncate">{vendor.vendorName}</span>
- </div>
- </CommandItem>
- ))}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
-
- {/* 선택된 업체 목록 */}
- {selectedVendors.length > 0 && (
- <div className="space-y-2">
- <div className="flex items-center justify-between">
- <h4 className="text-sm font-medium">선택된 업체 ({selectedVendors.length}개)</h4>
- </div>
- <div className="space-y-2">
- {selectedVendors.map((vendor, index) => (
- <div
- key={vendor.id}
- className="flex items-center justify-between p-3 rounded-lg bg-secondary/50"
- >
- <div className="flex items-center gap-3">
- <span className="text-sm text-muted-foreground">
- {index + 1}.
- </span>
- <Badge variant="outline">
- {vendor.vendorCode}
- </Badge>
- <span className="text-sm font-medium">
- {vendor.vendorName}
- </span>
- </div>
- <Button
- variant="ghost"
- size="sm"
- onClick={() => handleRemoveVendor(vendor.id)}
- className="h-8 w-8 p-0"
- >
- <X className="h-4 w-4" />
- </Button>
- </div>
- ))}
- </div>
- </div>
- )}
-
- {selectedVendors.length === 0 && (
- <div className="text-center py-8 text-muted-foreground">
- <p className="text-sm">아직 선택된 업체가 없습니다.</p>
- <p className="text-xs mt-1">위 버튼을 클릭하여 업체를 추가하세요.</p>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- </div>
- </div>
-
- {/* 푸터 */}
- <DialogFooter className="p-6 pt-0 border-t">
- <Button
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isPending}
- >
- 취소
- </Button>
- <Button
- onClick={handleCreate}
- disabled={isPending || selectedVendors.length === 0}
- >
- {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {selectedVendors.length > 0
- ? `${selectedVendors.length}개 업체 추가`
- : '업체 추가'
- }
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
deleted file mode 100644
index 03bf2ecb..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-edit-dialog.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Textarea } from '@/components/ui/textarea'
-import { Checkbox } from '@/components/ui/checkbox'
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from '@/components/ui/dialog'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { updateBiddingCompany } from '../service'
-import { BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-
-interface BiddingPreQuoteVendorEditDialogProps {
- company: BiddingCompany | null
- open: boolean
- onOpenChange: (open: boolean) => void
- onSuccess: () => void
-}
-
-export function BiddingPreQuoteVendorEditDialog({
- company,
- open,
- onOpenChange,
- onSuccess
-}: BiddingPreQuoteVendorEditDialogProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
-
- // 폼 상태
- const [formData, setFormData] = React.useState({
- contactPerson: '',
- contactEmail: '',
- contactPhone: '',
- preQuoteAmount: 0,
- notes: '',
- invitationStatus: 'pending' as 'pending' | 'accepted' | 'declined',
- isPreQuoteSelected: false,
- isAttendingMeeting: false,
- })
-
- // company가 변경되면 폼 데이터 업데이트
- React.useEffect(() => {
- if (company) {
- setFormData({
- contactPerson: company.contactPerson || '',
- contactEmail: company.contactEmail || '',
- contactPhone: company.contactPhone || '',
- preQuoteAmount: company.preQuoteAmount ? Number(company.preQuoteAmount) : 0,
- notes: company.notes || '',
- invitationStatus: company.invitationStatus,
- isPreQuoteSelected: company.isPreQuoteSelected,
- isAttendingMeeting: company.isAttendingMeeting || false,
- })
- }
- }, [company])
-
- const handleEdit = () => {
- if (!company) return
-
- startTransition(async () => {
- const response = await updateBiddingCompany(company.id, formData)
-
- if (response.success) {
- toast({
- title: '성공',
- description: response.message,
- })
- onOpenChange(false)
- onSuccess()
- } else {
- toast({
- title: '오류',
- description: response.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="sm:max-w-[600px]">
- <DialogHeader>
- <DialogTitle>사전견적 업체 수정</DialogTitle>
- <DialogDescription>
- {company?.companyName} 업체의 사전견적 정보를 수정해주세요.
- </DialogDescription>
- </DialogHeader>
- <div className="grid gap-4 py-4">
- <div className="grid grid-cols-3 gap-4">
- <div className="space-y-2">
- <Label htmlFor="edit-contactPerson">담당자</Label>
- <Input
- id="edit-contactPerson"
- value={formData.contactPerson}
- onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-contactEmail">이메일</Label>
- <Input
- id="edit-contactEmail"
- type="email"
- value={formData.contactEmail}
- onChange={(e) => setFormData({ ...formData, contactEmail: e.target.value })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-contactPhone">연락처</Label>
- <Input
- id="edit-contactPhone"
- value={formData.contactPhone}
- onChange={(e) => setFormData({ ...formData, contactPhone: e.target.value })}
- />
- </div>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div className="space-y-2">
- <Label htmlFor="edit-preQuoteAmount">사전견적금액</Label>
- <Input
- id="edit-preQuoteAmount"
- type="number"
- value={formData.preQuoteAmount}
- onChange={(e) => setFormData({ ...formData, preQuoteAmount: Number(e.target.value) })}
- />
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-invitationStatus">초대 상태</Label>
- <Select value={formData.invitationStatus} onValueChange={(value: any) => setFormData({ ...formData, invitationStatus: value })}>
- <SelectTrigger>
- <SelectValue />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="pending">대기중</SelectItem>
- <SelectItem value="accepted">수락</SelectItem>
- <SelectItem value="declined">거절</SelectItem>
- </SelectContent>
- </Select>
- </div>
- </div>
- <div className="grid grid-cols-2 gap-4">
- <div className="flex items-center space-x-2">
- <Checkbox
- id="edit-isPreQuoteSelected"
- checked={formData.isPreQuoteSelected}
- onCheckedChange={(checked) =>
- setFormData({ ...formData, isPreQuoteSelected: !!checked })
- }
- />
- <Label htmlFor="edit-isPreQuoteSelected">본입찰 선정</Label>
- </div>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="edit-isAttendingMeeting"
- checked={formData.isAttendingMeeting}
- onCheckedChange={(checked) =>
- setFormData({ ...formData, isAttendingMeeting: !!checked })
- }
- />
- <Label htmlFor="edit-isAttendingMeeting">사양설명회 참석</Label>
- </div>
- </div>
- <div className="space-y-2">
- <Label htmlFor="edit-notes">특이사항</Label>
- <Textarea
- id="edit-notes"
- value={formData.notes}
- onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
- placeholder="특이사항을 입력해주세요..."
- />
- </div>
- </div>
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 취소
- </Button>
- <Button onClick={handleEdit} disabled={isPending}>
- 수정
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
deleted file mode 100644
index 5f600882..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-table.tsx
+++ /dev/null
@@ -1,257 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { type DataTableAdvancedFilterField, type DataTableFilterField } 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 { BiddingPreQuoteVendorToolbarActions } from './bidding-pre-quote-vendor-toolbar-actions'
-import { BiddingPreQuoteVendorEditDialog } from './bidding-pre-quote-vendor-edit-dialog'
-import { getBiddingPreQuoteVendorColumns, BiddingCompany } from './bidding-pre-quote-vendor-columns'
-import { Bidding } from '@/db/schema'
-import {
- deleteBiddingCompany
-} from '../service'
-import { getPriceAdjustmentFormByBiddingCompanyId } from '@/lib/bidding/detail/service'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { PriceAdjustmentDialog } from '@/components/bidding/price-adjustment-dialog'
-import { BiddingPreQuoteItemDetailsDialog } from './bidding-pre-quote-item-details-dialog'
-import { BiddingPreQuoteAttachmentsDialog } from './bidding-pre-quote-attachments-dialog'
-import { getPrItemsForBidding } from '../service'
-
-interface BiddingPreQuoteVendorTableContentProps {
- biddingId: number
- bidding: Bidding
- biddingCompanies: BiddingCompany[]
- onRefresh: () => void
- onOpenItemsDialog: () => void
- onOpenTargetPriceDialog: () => void
- onOpenSelectionReasonDialog: () => void
- onEdit?: (company: BiddingCompany) => void
- onDelete?: (company: BiddingCompany) => void
-}
-
-const filterFields: DataTableFilterField<BiddingCompany>[] = [
- {
- id: 'companyName',
- label: '업체명',
- placeholder: '업체명으로 검색...',
- },
- {
- id: 'companyCode',
- label: '업체코드',
- placeholder: '업체코드로 검색...',
- },
- {
- id: 'contactPerson',
- label: '담당자',
- placeholder: '담당자로 검색...',
- },
-]
-
-const advancedFilterFields: DataTableAdvancedFilterField<BiddingCompany>[] = [
- {
- id: 'companyName',
- label: '업체명',
- type: 'text',
- },
- {
- id: 'companyCode',
- label: '업체코드',
- type: 'text',
- },
- {
- id: 'contactPerson',
- label: '담당자',
- type: 'text',
- },
- {
- id: 'preQuoteAmount',
- label: '사전견적금액',
- type: 'number',
- },
- {
- id: 'invitationStatus',
- label: '초대 상태',
- type: 'multi-select',
- options: [
- { label: '수락', value: 'accepted' },
- { label: '거절', value: 'declined' },
- { label: '요청됨', value: 'sent' },
- { label: '대기중', value: 'pending' },
- ],
- },
-]
-
-export function BiddingPreQuoteVendorTableContent({
- biddingId,
- bidding,
- biddingCompanies,
- onRefresh,
- onOpenItemsDialog,
- onOpenTargetPriceDialog,
- onOpenSelectionReasonDialog,
- onEdit,
- onDelete
-}: BiddingPreQuoteVendorTableContentProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [selectedCompany, setSelectedCompany] = React.useState<BiddingCompany | null>(null)
- const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
- const [isPriceAdjustmentDialogOpen, setIsPriceAdjustmentDialogOpen] = React.useState(false)
- const [priceAdjustmentData, setPriceAdjustmentData] = React.useState<any>(null)
- const [isItemDetailsDialogOpen, setIsItemDetailsDialogOpen] = React.useState(false)
- const [selectedCompanyForDetails, setSelectedCompanyForDetails] = React.useState<BiddingCompany | null>(null)
- const [prItems, setPrItems] = React.useState<any[]>([])
- const [isAttachmentsDialogOpen, setIsAttachmentsDialogOpen] = React.useState(false)
- const [selectedCompanyForAttachments, setSelectedCompanyForAttachments] = React.useState<BiddingCompany | null>(null)
-
- const handleDelete = (company: BiddingCompany) => {
- startTransition(async () => {
- const response = await deleteBiddingCompany(company.id)
-
- if (response.success) {
- toast({
- title: '성공',
- description: response.message,
- })
- onRefresh()
- } else {
- toast({
- title: '오류',
- description: response.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleEdit = (company: BiddingCompany) => {
- setSelectedCompany(company)
- setIsEditDialogOpen(true)
- }
-
-
- const handleViewPriceAdjustment = async (company: BiddingCompany) => {
- startTransition(async () => {
- const priceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(company.id)
- if (priceAdjustmentForm) {
- setPriceAdjustmentData(priceAdjustmentForm)
- setSelectedCompany(company)
- setIsPriceAdjustmentDialogOpen(true)
- } else {
- toast({
- title: '정보 없음',
- description: '연동제 정보가 없습니다.',
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleViewItemDetails = async (company: BiddingCompany) => {
- startTransition(async () => {
- try {
- // PR 아이템 정보 로드
- const prItemsData = await getPrItemsForBidding(biddingId)
- setPrItems(prItemsData)
- setSelectedCompanyForDetails(company)
- setIsItemDetailsDialogOpen(true)
- } catch (error) {
- console.error('Failed to load PR items:', error)
- toast({
- title: '오류',
- description: '품목 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleViewAttachments = (company: BiddingCompany) => {
- setSelectedCompanyForAttachments(company)
- setIsAttachmentsDialogOpen(true)
- }
-
- const columns = React.useMemo(
- () => getBiddingPreQuoteVendorColumns({
- onEdit: onEdit || handleEdit,
- onDelete: onDelete || handleDelete,
- onViewPriceAdjustment: handleViewPriceAdjustment,
- onViewItemDetails: handleViewItemDetails,
- onViewAttachments: handleViewAttachments
- }),
- [onEdit, onDelete, handleEdit, handleDelete, handleViewPriceAdjustment, handleViewItemDetails, handleViewAttachments]
- )
-
- const { table } = useDataTable({
- data: biddingCompanies,
- columns,
- pageCount: 1,
- filterFields,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: 'companyName', desc: false }],
- columnPinning: { right: ['actions'] },
- },
- getRowId: (originalRow) => originalRow.id.toString(),
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
- <>
- <DataTable table={table}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <BiddingPreQuoteVendorToolbarActions
- table={table}
- biddingId={biddingId}
- bidding={bidding}
- biddingCompanies={biddingCompanies}
- onOpenItemsDialog={onOpenItemsDialog}
- onOpenTargetPriceDialog={onOpenTargetPriceDialog}
- onOpenSelectionReasonDialog={onOpenSelectionReasonDialog}
- onSuccess={onRefresh}
- />
- </DataTableAdvancedToolbar>
- </DataTable>
-
- <BiddingPreQuoteVendorEditDialog
- company={selectedCompany}
- open={isEditDialogOpen}
- onOpenChange={setIsEditDialogOpen}
- onSuccess={onRefresh}
- />
-
- <PriceAdjustmentDialog
- open={isPriceAdjustmentDialogOpen}
- onOpenChange={setIsPriceAdjustmentDialogOpen}
- data={priceAdjustmentData}
- vendorName={selectedCompany?.companyName || ''}
- />
-
- <BiddingPreQuoteItemDetailsDialog
- open={isItemDetailsDialogOpen}
- onOpenChange={setIsItemDetailsDialogOpen}
- biddingId={biddingId}
- biddingCompanyId={selectedCompanyForDetails?.id || 0}
- companyName={selectedCompanyForDetails?.companyName || ''}
- prItems={prItems}
- currency={bidding.currency || 'KRW'}
- />
-
- <BiddingPreQuoteAttachmentsDialog
- open={isAttachmentsDialogOpen}
- onOpenChange={setIsAttachmentsDialogOpen}
- biddingId={biddingId}
- companyId={selectedCompanyForAttachments?.companyId || 0}
- companyName={selectedCompanyForAttachments?.companyName || ''}
- />
- </>
- )
-}
diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
deleted file mode 100644
index 34e53fb2..00000000
--- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-toolbar-actions.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { type Table } from "@tanstack/react-table"
-import { useTransition } from "react"
-import { Button } from "@/components/ui/button"
-import { Plus, Send, Mail, CheckSquare } from "lucide-react"
-import { BiddingCompany } from "./bidding-pre-quote-vendor-columns"
-import { BiddingPreQuoteVendorCreateDialog } from "./bidding-pre-quote-vendor-create-dialog"
-import { BiddingPreQuoteInvitationDialog } from "./bidding-pre-quote-invitation-dialog"
-import { BiddingPreQuoteSelectionDialog } from "./bidding-pre-quote-selection-dialog"
-import { Bidding } from "@/db/schema"
-import { useToast } from "@/hooks/use-toast"
-
-interface BiddingPreQuoteVendorToolbarActionsProps {
- table: Table<BiddingCompany>
- biddingId: number
- bidding: Bidding
- biddingCompanies: BiddingCompany[]
- onOpenItemsDialog: () => void
- onOpenTargetPriceDialog: () => void
- onOpenSelectionReasonDialog: () => void
- onSuccess: () => void
-}
-
-export function BiddingPreQuoteVendorToolbarActions({
- table,
- biddingId,
- bidding,
- biddingCompanies,
- onOpenItemsDialog,
- onOpenTargetPriceDialog,
- onOpenSelectionReasonDialog,
- onSuccess
-}: BiddingPreQuoteVendorToolbarActionsProps) {
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false)
- const [isInvitationDialogOpen, setIsInvitationDialogOpen] = React.useState(false)
- const [isSelectionDialogOpen, setIsSelectionDialogOpen] = React.useState(false)
-
- const handleCreateCompany = () => {
- setIsCreateDialogOpen(true)
- }
-
- const handleSendInvitations = () => {
- setIsInvitationDialogOpen(true)
- }
-
- const handleManageSelection = () => {
- const selectedRows = table.getFilteredSelectedRowModel().rows
- if (selectedRows.length === 0) {
- toast({
- title: '선택 필요',
- description: '본입찰 선정 상태를 변경할 업체를 선택해주세요.',
- variant: 'destructive',
- })
- return
- }
- setIsSelectionDialogOpen(true)
- }
-
-
-
- return (
- <>
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleCreateCompany}
- disabled={isPending}
- >
- <Plus className="mr-2 h-4 w-4" />
- 업체 추가
- </Button>
-
- <Button
- variant="default"
- size="sm"
- onClick={handleSendInvitations}
- disabled={isPending}
- >
- <Mail className="mr-2 h-4 w-4" />
- 초대 발송
- </Button>
-
- <Button
- variant="secondary"
- size="sm"
- onClick={handleManageSelection}
- disabled={isPending}
- >
- <CheckSquare className="mr-2 h-4 w-4" />
- 본입찰 선정
- </Button>
- </div>
-
- <BiddingPreQuoteVendorCreateDialog
- biddingId={biddingId}
- open={isCreateDialogOpen}
- onOpenChange={setIsCreateDialogOpen}
- onSuccess={() => {
- onSuccess()
- setIsCreateDialogOpen(false)
- }}
- />
-
- <BiddingPreQuoteInvitationDialog
- open={isInvitationDialogOpen}
- onOpenChange={setIsInvitationDialogOpen}
- companies={biddingCompanies}
- biddingId={biddingId}
- biddingTitle={bidding.title}
- projectName={bidding.projectName}
- onSuccess={onSuccess}
- />
-
- <BiddingPreQuoteSelectionDialog
- open={isSelectionDialogOpen}
- onOpenChange={setIsSelectionDialogOpen}
- selectedCompanies={table.getFilteredSelectedRowModel().rows.map(row => row.original)}
- onSuccess={() => {
- onSuccess()
- table.resetRowSelection()
- }}
- />
- </>
- )
-}
diff --git a/lib/bidding/receive/biddings-receive-columns.tsx b/lib/bidding/receive/biddings-receive-columns.tsx
new file mode 100644
index 00000000..724a7396
--- /dev/null
+++ b/lib/bidding/receive/biddings-receive-columns.tsx
@@ -0,0 +1,360 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Eye, Calendar, Users, CheckCircle, XCircle, Clock, AlertTriangle
+} 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 {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingReceiveItem> | null>>
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'received_quotation':
+ return 'secondary'
+ case 'bidding_opened':
+ return 'default'
+ case 'bidding_closed':
+ return 'outline'
+ 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 getBiddingsReceiveColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingReceiveItem>[] {
+
+ return [
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ </div>
+ ),
+ size: 120,
+ 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 font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ 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: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 입찰서제출기간 ░░░
+ {
+ 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: "입찰서제출기간" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 입찰담당자 ░░░
+ {
+ accessorKey: "bidPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return <span className="text-sm">{displayName}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ 참여예정협력사 ░░░
+ {
+ id: "participantExpected",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여예정협력사" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <Users className="h-4 w-4 text-blue-500" />
+ <span className="text-sm font-medium">{row.original.participantExpected}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여예정협력사" },
+ },
+
+ // ░░░ 참여협력사 ░░░
+ {
+ id: "participantParticipated",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여협력사" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <CheckCircle className="h-4 w-4 text-green-500" />
+ <span className="text-sm font-medium">{row.original.participantParticipated}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "참여협력사" },
+ },
+
+ // ░░░ 포기협력사 ░░░
+ {
+ id: "participantDeclined",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="포기협력사" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <XCircle className="h-4 w-4 text-red-500" />
+ <span className="text-sm font-medium">{row.original.participantDeclined}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "포기협력사" },
+ },
+
+ // ░░░ 미제출협력사 ░░░
+ {
+ id: "participantPending",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="미제출협력사" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <Clock className="h-4 w-4 text-yellow-500" />
+ <span className="text-sm font-medium">{row.original.participantPending}</span>
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "미제출협력사" },
+ },
+
+ // ░░░ 개찰자명 ░░░
+ {
+ id: "openedBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰자명" />,
+ cell: ({ row }) => {
+ const openedBy = row.original.openedBy
+ return <span className="text-sm">{openedBy || '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰자명" },
+ },
+
+ // ░░░ 개찰일 ░░░
+ {
+ id: "openedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="개찰일" />,
+ cell: ({ row }) => {
+ const openedAt = row.original.openedAt
+ return <span className="text-sm">{openedAt ? formatDate(openedAt, "KR") : '-'}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "개찰일" },
+ },
+
+ // ░░░ 등록자 ░░░
+ {
+ accessorKey: "createdBy",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.createdBy || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "등록자" },
+ },
+
+ // ░░░ 등록일시 ░░░
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="등록일시" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.createdAt, "KR")}</span>
+ ),
+ size: 100,
+ 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>
+ <AlertTriangle 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>
+ {row.original.status === 'bidding_closed' && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "open_bidding" })}>
+ <Calendar className="mr-2 h-4 w-4" />
+ 개찰하기
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+}
diff --git a/lib/bidding/receive/biddings-receive-table.tsx b/lib/bidding/receive/biddings-receive-table.tsx
new file mode 100644
index 00000000..88fade40
--- /dev/null
+++ b/lib/bidding/receive/biddings-receive-table.tsx
@@ -0,0 +1,211 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+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 { getBiddingsReceiveColumns } from "./biddings-receive-columns"
+import { getBiddingsForReceive } from "@/lib/bidding/service"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+
+type BiddingReceiveItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 참여 현황
+ participantExpected: number
+ participantParticipated: number
+ participantDeclined: number
+ participantPending: number
+
+ // 개찰 정보
+ openedAt: Date | null
+ openedBy: string | null
+}
+
+interface BiddingsReceiveTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBiddingsForReceive>>
+ ]
+ >
+}
+
+export function BiddingsReceiveTable({ promises }: BiddingsReceiveTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingReceiveItem | null>(null)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingReceiveItem> | null>(null)
+
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ const columns = React.useMemo(
+ () => getBiddingsReceiveColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // rowAction 변경 감지하여 해당 다이얼로그 열기
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedBidding(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ break
+ case "open_bidding":
+ // 개찰하기 (추후 구현)
+ console.log('개찰하기:', rowAction.row.original)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction])
+
+ const filterFields: DataTableFilterField<BiddingReceiveItem>[] = [
+ {
+ id: "biddingNumber",
+ label: "입찰번호",
+ type: "text",
+ placeholder: "입찰번호를 입력하세요",
+ },
+ {
+ id: "prNumber",
+ label: "P/R번호",
+ type: "text",
+ placeholder: "P/R번호를 입력하세요",
+ },
+ {
+ id: "title",
+ label: "입찰명",
+ type: "text",
+ placeholder: "입찰명을 입력하세요",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<BiddingReceiveItem>[] = [
+ { id: "title", label: "입찰명", type: "text" },
+ { id: "biddingNumber", label: "입찰번호", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
+ {
+ id: "status",
+ label: "진행상태",
+ type: "multi-select",
+ options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "contractType",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "submissionStartDate", label: "제출시작일", type: "date" },
+ { id: "submissionEndDate", 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)
+ }, [])
+
+ const handleSpecMeetingDialogClose = React.useCallback(() => {
+ setSpecMeetingDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
+ const handlePrDocumentsDialogClose = React.useCallback(() => {
+ setPrDocumentsDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsReceiveTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 사양설명회 다이얼로그 */}
+ <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ />
+
+ {/* PR 문서 다이얼로그 */}
+ <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/selection/biddings-selection-columns.tsx b/lib/bidding/selection/biddings-selection-columns.tsx
new file mode 100644
index 00000000..bbcd2d77
--- /dev/null
+++ b/lib/bidding/selection/biddings-selection-columns.tsx
@@ -0,0 +1,289 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Eye, Calendar, FileText, DollarSign, TrendingUp, TrendingDown
+} 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 {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { formatDate } from "@/lib/utils"
+import { DataTableRowAction } from "@/types/table"
+
+type BiddingSelectionItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 입찰 결과 정보 (개찰 이후에만 의미 있음)
+ participantCount?: number
+ submittedCount?: number
+ avgBidPrice?: number | null
+ minBidPrice?: number | null
+ maxBidPrice?: number | null
+ targetPrice?: number | null
+ currency?: string | null
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingSelectionItem> | null>>
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'bidding_opened':
+ return 'default'
+ case 'bidding_closed':
+ return 'outline'
+ case 'evaluation_of_bidding':
+ return 'secondary'
+ case 'vendor_selected':
+ return 'default'
+ 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 getBiddingsSelectionColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingSelectionItem>[] {
+
+ return [
+ // ░░░ 입찰번호 ░░░
+ {
+ accessorKey: "biddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.biddingNumber}
+ </div>
+ ),
+ size: 120,
+ 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 font-bold underline"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <div className="whitespace-pre-line">
+ {row.original.title}
+ </div>
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "입찰명" },
+ },
+
+ // ░░░ 원입찰번호 ░░░
+ {
+ accessorKey: "originalBiddingNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="원입찰번호" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.originalBiddingNumber || '-'}
+ </div>
+ ),
+ size: 120,
+ meta: { excelHeader: "원입찰번호" },
+ },
+
+ // ░░░ 진행상태 ░░░
+ {
+ 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: "진행상태" },
+ },
+
+ // ░░░ 계약구분 ░░░
+ {
+ accessorKey: "contractType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {contractTypeLabels[row.original.contractType]}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ // ░░░ 입찰제출기간 ░░░
+ {
+ 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 isPast = now > new Date(endDate)
+ const isClosed = isPast
+
+ return (
+ <div className="text-xs">
+ <div className={`${isClosed ? 'text-red-600' : 'text-gray-600'}`}>
+ {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ </div>
+ {isClosed && (
+ <Badge variant="destructive" className="text-xs mt-1">마감</Badge>
+ )}
+ </div>
+ )
+ },
+ size: 140,
+ meta: { excelHeader: "입찰제출기간" },
+ },
+
+ // ░░░ 입찰담당자 ░░░
+ {
+ accessorKey: "bidPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="입찰담당자" />,
+ cell: ({ row }) => {
+ const bidPic = row.original.bidPicName
+ const supplyPic = row.original.supplyPicName
+
+ const displayName = bidPic || supplyPic || "-"
+ return <span className="text-sm">{displayName}</span>
+ },
+ size: 100,
+ meta: { excelHeader: "입찰담당자" },
+ },
+
+ // ░░░ P/R번호 ░░░
+ {
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="P/R번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "P/R번호" },
+ },
+
+ // ░░░ 참여업체수 ░░░
+ {
+ id: "participantCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="참여업체수" />,
+ cell: ({ row }) => {
+ const count = row.original.participantCount || 0
+ return (
+ <div className="flex items-center gap-1">
+ <span className="text-sm font-medium">{count}</span>
+ </div>
+ )
+ },
+ size: 100,
+ 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>
+ <FileText 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: "detail" })}>
+ <FileText className="mr-2 h-4 w-4" />
+ 상세분석
+ </DropdownMenuItem>
+ {row.original.status === 'bidding_opened' && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "close_bidding" })}>
+ <Calendar className="mr-2 h-4 w-4" />
+ 입찰마감
+ </DropdownMenuItem>
+ </>
+ )}
+ {row.original.status === 'bidding_closed' && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "evaluate_bidding" })}>
+ <DollarSign className="mr-2 h-4 w-4" />
+ 평가하기
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+}
diff --git a/lib/bidding/selection/biddings-selection-table.tsx b/lib/bidding/selection/biddings-selection-table.tsx
new file mode 100644
index 00000000..912a7154
--- /dev/null
+++ b/lib/bidding/selection/biddings-selection-table.tsx
@@ -0,0 +1,218 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+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 { getBiddingsSelectionColumns } from "./biddings-selection-columns"
+import { getBiddingsForSelection } from "@/lib/bidding/service"
+import {
+ biddingStatusLabels,
+ contractTypeLabels,
+} from "@/db/schema"
+import { SpecificationMeetingDialog, PrDocumentsDialog } from "../list/bidding-detail-dialogs"
+
+type BiddingSelectionItem = {
+ id: number
+ biddingNumber: string
+ originalBiddingNumber: string | null
+ title: string
+ status: string
+ contractType: string
+ prNumber: string | null
+ submissionStartDate: Date | null
+ submissionEndDate: Date | null
+ bidPicName: string | null
+ supplyPicName: string | null
+ createdBy: string | null
+ createdAt: Date | null
+ updatedAt: Date | null
+
+ // 입찰 결과 정보 (개찰 이후에만 의미 있음)
+ participantCount?: number
+ submittedCount?: number
+ avgBidPrice?: number | null
+ minBidPrice?: number | null
+ maxBidPrice?: number | null
+ targetPrice?: number | null
+ currency?: string | null
+}
+
+interface BiddingsSelectionTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getBiddingsForSelection>>
+ ]
+ >
+}
+
+export function BiddingsSelectionTable({ promises }: BiddingsSelectionTableProps) {
+ const [biddingsResult] = React.use(promises)
+
+ // biddingsResult에서 data와 pageCount 추출
+ const { data, pageCount } = biddingsResult
+
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ const [specMeetingDialogOpen, setSpecMeetingDialogOpen] = React.useState(false)
+ const [prDocumentsDialogOpen, setPrDocumentsDialogOpen] = React.useState(false)
+ const [selectedBidding, setSelectedBidding] = React.useState<BiddingSelectionItem | null>(null)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<BiddingSelectionItem> | null>(null)
+
+ const router = useRouter()
+ const { data: session } = useSession()
+
+ const columns = React.useMemo(
+ () => getBiddingsSelectionColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // rowAction 변경 감지하여 해당 다이얼로그 열기
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedBidding(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/bid/${rowAction.row.original.id}`)
+ break
+ case "detail":
+ // 상세분석 페이지로 이동 (추후 구현)
+ router.push(`/evcp/bid-selection/${rowAction.row.original.id}/detail`)
+ break
+ case "close_bidding":
+ // 입찰마감 (추후 구현)
+ console.log('입찰마감:', rowAction.row.original)
+ break
+ case "evaluate_bidding":
+ // 평가하기 (추후 구현)
+ console.log('평가하기:', rowAction.row.original)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction])
+
+ const filterFields: DataTableFilterField<BiddingSelectionItem>[] = [
+ {
+ id: "biddingNumber",
+ label: "입찰번호",
+ type: "text",
+ placeholder: "입찰번호를 입력하세요",
+ },
+ {
+ id: "prNumber",
+ label: "P/R번호",
+ type: "text",
+ placeholder: "P/R번호를 입력하세요",
+ },
+ {
+ id: "title",
+ label: "입찰명",
+ type: "text",
+ placeholder: "입찰명을 입력하세요",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<BiddingSelectionItem>[] = [
+ { id: "title", label: "입찰명", type: "text" },
+ { id: "biddingNumber", label: "입찰번호", type: "text" },
+ { id: "bidPicName", label: "입찰담당자", type: "text" },
+ {
+ id: "status",
+ label: "진행상태",
+ type: "multi-select",
+ options: Object.entries(biddingStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "contractType",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ { id: "createdAt", label: "등록일", type: "date" },
+ { id: "submissionStartDate", label: "제출시작일", type: "date" },
+ { id: "submissionEndDate", 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)
+ }, [])
+
+ const handleSpecMeetingDialogClose = React.useCallback(() => {
+ setSpecMeetingDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
+ const handlePrDocumentsDialogClose = React.useCallback(() => {
+ setPrDocumentsDialogOpen(false)
+ setRowAction(null)
+ setSelectedBidding(null)
+ }, [])
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="biddingsSelectionTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 사양설명회 다이얼로그 */}
+ <SpecificationMeetingDialog
+ open={specMeetingDialogOpen}
+ onOpenChange={handleSpecMeetingDialogClose}
+ bidding={selectedBidding}
+ />
+
+ {/* PR 문서 다이얼로그 */}
+ <PrDocumentsDialog
+ open={prDocumentsDialogOpen}
+ onOpenChange={handlePrDocumentsDialogClose}
+ bidding={selectedBidding}
+ />
+ </>
+ )
+}
diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts
index 5ab18ef1..80e4850f 100644
--- a/lib/bidding/service.ts
+++ b/lib/bidding/service.ts
@@ -3,7 +3,6 @@
import db from '@/db/db'
import {
biddings,
- biddingListView,
biddingNoticeTemplate,
projects,
biddingDocuments,
@@ -14,7 +13,10 @@ import {
users,
basicContractTemplates,
vendorsWithTypesView,
- biddingCompanies
+ biddingCompanies,
+ biddingCompaniesContacts,
+ vendorContacts,
+ vendors
} from '@/db/schema'
import {
eq,
@@ -69,13 +71,40 @@ async function getUserNameById(userId: string): Promise<string> {
}
-export async function getBiddingNoticeTemplate() {
+export async function getBiddingNoticeTemplates() {
try {
const result = await db
.select()
.from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.type, 'standard'))
- .limit(1)
+ .where(eq(biddingNoticeTemplate.isTemplate, true))
+ .orderBy(desc(biddingNoticeTemplate.updatedAt))
+
+ // 타입별로 그룹화하여 반환
+ const templates = result.reduce((acc, template) => {
+ acc[template.type] = template
+ return acc
+ }, {} as Record<string, typeof result[0]>)
+
+ return templates
+ } catch (error) {
+ console.error('Failed to get bidding notice templates:', error)
+ throw new Error('입찰공고문 템플릿을 불러오는데 실패했습니다.')
+ }
+}
+
+export async function getBiddingNoticeTemplate(type?: string) {
+ try {
+ let query = db
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.isTemplate, true))
+ .orderBy(desc(biddingNoticeTemplate.updatedAt))
+
+ if (type) {
+ query = query.where(eq(biddingNoticeTemplate.type, type))
+ }
+
+ const result = await query.limit(1)
return result[0] || null
} catch (error) {
@@ -84,18 +113,41 @@ export async function getBiddingNoticeTemplate() {
}
}
-export async function saveBiddingNoticeTemplate(formData: {
+export async function getBiddingNotice(biddingId: number) {
+ try {
+ const result = await db
+ .select({
+ id: biddingNoticeTemplate.id,
+ biddingId: biddingNoticeTemplate.biddingId,
+ title: biddingNoticeTemplate.title,
+ content: biddingNoticeTemplate.content,
+ isTemplate: biddingNoticeTemplate.isTemplate,
+ createdAt: biddingNoticeTemplate.createdAt,
+ updatedAt: biddingNoticeTemplate.updatedAt,
+ })
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
+ .limit(1)
+
+ return result[0] || null
+ } catch (error) {
+ console.error('Failed to get bidding notice:', error)
+ throw new Error('입찰공고문을 불러오는데 실패했습니다.')
+ }
+}
+
+export async function saveBiddingNotice(biddingId: number, formData: {
title: string
content: string
}) {
try {
const { title, content } = formData
- // 기존 템플릿 확인
+ // 기존 입찰공고 확인
const existing = await db
.select()
.from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.type, 'standard'))
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
.limit(1)
if (existing.length > 0) {
@@ -107,13 +159,62 @@ export async function saveBiddingNoticeTemplate(formData: {
content,
updatedAt: new Date(),
})
- .where(eq(biddingNoticeTemplate.type, 'standard'))
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
} else {
// 새로 생성
await db.insert(biddingNoticeTemplate).values({
- type: 'standard',
+ biddingId,
title,
content,
+ isTemplate: false,
+ })
+ }
+
+ return { success: true, message: '입찰공고문이 저장되었습니다.' }
+ } catch (error) {
+ console.error('Failed to save bidding notice:', error)
+ throw new Error('입찰공고문 저장에 실패했습니다.')
+ }
+}
+
+export async function saveBiddingNoticeTemplate(formData: {
+ title: string
+ content: string
+ type: string
+}) {
+ try {
+ const { title, content, type } = formData
+
+ // 기존 동일 타입의 템플릿 확인
+ const existing = await db
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(and(
+ eq(biddingNoticeTemplate.isTemplate, true),
+ eq(biddingNoticeTemplate.type, type)
+ ))
+ .limit(1)
+
+ if (existing.length > 0) {
+ // 업데이트
+ await db
+ .update(biddingNoticeTemplate)
+ .set({
+ title,
+ content,
+ updatedAt: new Date(),
+ })
+ .where(and(
+ eq(biddingNoticeTemplate.isTemplate, true),
+ eq(biddingNoticeTemplate.type, type)
+ ))
+ } else {
+ // 새로 생성
+ await db.insert(biddingNoticeTemplate).values({
+ title,
+ content,
+ type,
+ isTemplate: true,
})
}
@@ -137,7 +238,7 @@ export async function getBiddings(input: GetBiddingsSchema) {
let advancedWhere: SQL<unknown> | undefined = undefined
if (input.filters && input.filters.length > 0) {
advancedWhere = filterColumns({
- table: biddingListView,
+ table: biddings,
filters: input.filters,
joinOperator: input.joinOperator || 'and',
})
@@ -147,72 +248,83 @@ export async function getBiddings(input: GetBiddingsSchema) {
const basicConditions: SQL<unknown>[] = []
if (input.biddingNumber) {
- basicConditions.push(ilike(biddingListView.biddingNumber, `%${input.biddingNumber}%`))
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
}
if (input.status && input.status.length > 0) {
basicConditions.push(
- or(...input.status.map(status => eq(biddingListView.status, status)))!
+ or(...input.status.map(status => eq(biddings.status, status)))!
)
}
if (input.biddingType && input.biddingType.length > 0) {
basicConditions.push(
- or(...input.biddingType.map(type => eq(biddingListView.biddingType, type)))!
+ or(...input.biddingType.map(type => eq(biddings.biddingType, type)))!
)
}
if (input.contractType && input.contractType.length > 0) {
basicConditions.push(
- or(...input.contractType.map(type => eq(biddingListView.contractType, type)))!
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
)
}
+ if (input.purchasingOrganization) {
+ basicConditions.push(ilike(biddings.purchasingOrganization, `%${input.purchasingOrganization}%`))
+ }
+
+ // 담당자 필터 (bidPicId 또는 supplyPicId로 검색)
if (input.managerName) {
- basicConditions.push(ilike(biddingListView.managerName, `%${input.managerName}%`))
+ // managerName으로 검색 시 bidPic 또는 supplyPic의 이름으로 검색
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
}
// 날짜 필터들
if (input.preQuoteDateFrom) {
- basicConditions.push(gte(biddingListView.preQuoteDate, input.preQuoteDateFrom))
+ basicConditions.push(gte(biddings.preQuoteDate, input.preQuoteDateFrom))
}
if (input.preQuoteDateTo) {
- basicConditions.push(lte(biddingListView.preQuoteDate, input.preQuoteDateTo))
+ basicConditions.push(lte(biddings.preQuoteDate, input.preQuoteDateTo))
}
if (input.submissionDateFrom) {
- basicConditions.push(gte(biddingListView.submissionStartDate, new Date(input.submissionDateFrom)))
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
}
if (input.submissionDateTo) {
- basicConditions.push(lte(biddingListView.submissionEndDate, new Date(input.submissionDateTo)))
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
}
if (input.createdAtFrom) {
- basicConditions.push(gte(biddingListView.createdAt, new Date(input.createdAtFrom)))
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
}
if (input.createdAtTo) {
- basicConditions.push(lte(biddingListView.createdAt, new Date(input.createdAtTo)))
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
}
// 가격 범위 필터
if (input.budgetMin) {
- basicConditions.push(gte(biddingListView.budget, input.budgetMin))
+ basicConditions.push(gte(biddings.budget, input.budgetMin))
}
if (input.budgetMax) {
- basicConditions.push(lte(biddingListView.budget, input.budgetMax))
+ basicConditions.push(lte(biddings.budget, input.budgetMax))
}
// Boolean 필터
if (input.hasSpecificationMeeting === "true") {
- basicConditions.push(eq(biddingListView.hasSpecificationMeeting, true))
+ basicConditions.push(eq(biddings.hasSpecificationMeeting, true))
} else if (input.hasSpecificationMeeting === "false") {
- basicConditions.push(eq(biddingListView.hasSpecificationMeeting, false))
+ basicConditions.push(eq(biddings.hasSpecificationMeeting, false))
}
if (input.hasPrDocument === "true") {
- basicConditions.push(eq(biddingListView.hasPrDocument, true))
+ basicConditions.push(eq(biddings.hasPrDocument, true))
} else if (input.hasPrDocument === "false") {
- basicConditions.push(eq(biddingListView.hasPrDocument, false))
+ basicConditions.push(eq(biddings.hasPrDocument, false))
}
const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
@@ -222,13 +334,15 @@ export async function getBiddings(input: GetBiddingsSchema) {
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),
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.itemName, s),
+ ilike(biddings.purchasingOrganization, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.remarks, s),
]
globalWhere = or(...searchConditions)
}
@@ -244,7 +358,7 @@ export async function getBiddings(input: GetBiddingsSchema) {
// ✅ 5) 전체 개수 조회
const totalResult = await db
.select({ count: count() })
- .from(biddingListView)
+ .from(biddings)
.where(finalWhere)
const total = totalResult[0]?.count || 0
@@ -257,18 +371,444 @@ export async function getBiddings(input: GetBiddingsSchema) {
// ✅ 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])
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
})
if (orderByColumns.length === 0) {
- orderByColumns.push(desc(biddingListView.createdAt))
+ orderByColumns.push(desc(biddings.createdAt))
}
- // ✅ 7) 메인 쿼리 - 매우 간단해짐!
+ // ✅ 7) 메인 쿼리 - 이제 조인이 필요함!
const data = await db
- .select()
- .from(biddingListView)
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ revision: biddings.revision,
+ projectName: biddings.projectName,
+ itemName: biddings.itemName,
+ title: biddings.title,
+ description: biddings.description,
+ biddingSourceType: biddings.biddingSourceType,
+ isUrgent: biddings.isUrgent,
+
+ // 계약 정보
+ contractType: biddings.contractType,
+ biddingType: biddings.biddingType,
+ awardCount: biddings.awardCount,
+ contractStartDate: biddings.contractStartDate,
+ contractEndDate: biddings.contractEndDate,
+
+ // 일정 관리
+ preQuoteDate: biddings.preQuoteDate,
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ evaluationDate: biddings.evaluationDate,
+
+ // 회의 및 문서
+ hasSpecificationMeeting: biddings.hasSpecificationMeeting,
+ hasPrDocument: biddings.hasPrDocument,
+ prNumber: biddings.prNumber,
+
+ // 가격 정보
+ currency: biddings.currency,
+ budget: biddings.budget,
+ targetPrice: biddings.targetPrice,
+ finalBidPrice: biddings.finalBidPrice,
+
+ // 상태 및 담당자
+ status: biddings.status,
+ isPublic: biddings.isPublic,
+ purchasingOrganization: biddings.purchasingOrganization,
+ bidPicId: biddings.bidPicId,
+ bidPicName: biddings.bidPicName,
+ supplyPicId: biddings.supplyPicId,
+ supplyPicName: biddings.supplyPicName,
+
+ // 메타 정보
+ remarks: biddings.remarks,
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+ updatedBy: biddings.updatedBy,
+
+ // 사양설명회 상세 정보
+ hasSpecificationMeetingDetails: sql<boolean>`${specificationMeetings.id} IS NOT NULL`.as('has_specification_meeting_details'),
+ meetingDate: specificationMeetings.meetingDate,
+ meetingLocation: specificationMeetings.location,
+ meetingContactPerson: specificationMeetings.contactPerson,
+ meetingIsRequired: specificationMeetings.isRequired,
+
+ // PR 문서 집계
+ prDocumentCount: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM pr_documents
+ WHERE bidding_id = ${biddings.id}
+ ), 0)
+ `.as('pr_document_count'),
+
+ prDocumentNames: sql<string[]>`
+ (
+ SELECT array_agg(document_name ORDER BY registered_at DESC)
+ FROM pr_documents
+ WHERE bidding_id = ${biddings.id}
+ LIMIT 5
+ )
+ `.as('pr_document_names'),
+
+ // 참여 현황 집계 (전체)
+ participantExpected: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ), 0)
+ `.as('participant_expected'),
+
+ // === 사전견적 참여 현황 ===
+ preQuotePending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pending', 'pre_quote_sent')
+ ), 0)
+ `.as('pre_quote_pending'),
+
+ preQuoteAccepted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_accepted'
+ ), 0)
+ `.as('pre_quote_accepted'),
+
+ preQuoteDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_declined'
+ ), 0)
+ `.as('pre_quote_declined'),
+
+ preQuoteSubmitted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_submitted'
+ ), 0)
+ `.as('pre_quote_submitted'),
+
+ // === 입찰 참여 현황 ===
+ biddingPending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_sent'
+ ), 0)
+ `.as('bidding_pending'),
+
+ biddingAccepted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_accepted'
+ ), 0)
+ `.as('bidding_accepted'),
+
+ biddingDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_declined'
+ ), 0)
+ `.as('bidding_declined'),
+
+ biddingCancelled: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_cancelled'
+ ), 0)
+ `.as('bidding_cancelled'),
+
+ biddingSubmitted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_submitted'
+ ), 0)
+ `.as('bidding_submitted'),
+
+ // === 호환성을 위한 기존 컬럼 (사전견적 기준) ===
+ participantParticipated: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'pre_quote_submitted'
+ ), 0)
+ `.as('participant_participated'),
+
+ participantDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pre_quote_declined', 'bidding_declined')
+ ), 0)
+ `.as('participant_declined'),
+
+ participantPending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pending', 'pre_quote_sent', 'bidding_sent')
+ ), 0)
+ `.as('participant_pending'),
+
+ participantAccepted: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pre_quote_accepted', 'bidding_accepted')
+ ), 0)
+ `.as('participant_accepted'),
+
+ // 참여율 계산 (입찰 기준 - 응찰 완료 / 전체)
+ participationRate: sql<number>`
+ CASE
+ WHEN (
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ) > 0
+ THEN ROUND(
+ (
+ SELECT count(*)::decimal
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_submitted'
+ ) / (
+ SELECT count(*)::decimal
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ) * 100, 1
+ )
+ ELSE 0
+ END
+ `.as('participation_rate'),
+
+ // 견적 금액 통계
+ avgPreQuoteAmount: sql<number>`
+ (
+ SELECT AVG(pre_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND pre_quote_amount IS NOT NULL
+ )
+ `.as('avg_pre_quote_amount'),
+
+ minPreQuoteAmount: sql<number>`
+ (
+ SELECT MIN(pre_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND pre_quote_amount IS NOT NULL
+ )
+ `.as('min_pre_quote_amount'),
+
+ maxPreQuoteAmount: sql<number>`
+ (
+ SELECT MAX(pre_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND pre_quote_amount IS NOT NULL
+ )
+ `.as('max_pre_quote_amount'),
+
+ avgFinalQuoteAmount: sql<number>`
+ (
+ SELECT AVG(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ )
+ `.as('avg_final_quote_amount'),
+
+ minFinalQuoteAmount: sql<number>`
+ (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ )
+ `.as('min_final_quote_amount'),
+
+ maxFinalQuoteAmount: sql<number>`
+ (
+ SELECT MAX(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ )
+ `.as('max_final_quote_amount'),
+
+ // 선정 및 낙찰 정보
+ selectedForFinalBidCount: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND is_pre_quote_selected = true
+ ), 0)
+ `.as('selected_for_final_bid_count'),
+
+ winnerCount: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND is_winner = true
+ ), 0)
+ `.as('winner_count'),
+
+ winnerCompanyNames: sql<string[]>`
+ (
+ SELECT array_agg(v.vendor_name ORDER BY v.vendor_name)
+ FROM bidding_companies bc
+ JOIN vendors v ON bc.company_id = v.id
+ WHERE bc.bidding_id = ${biddings.id}
+ AND bc.is_winner = true
+ )
+ `.as('winner_company_names'),
+
+ // 일정 상태 계산
+ submissionStatus: sql<string>`
+ CASE
+ WHEN ${biddings.submissionStartDate} IS NULL OR ${biddings.submissionEndDate} IS NULL
+ THEN 'not_scheduled'
+ WHEN NOW() < ${biddings.submissionStartDate}
+ THEN 'scheduled'
+ WHEN NOW() BETWEEN ${biddings.submissionStartDate} AND ${biddings.submissionEndDate}
+ THEN 'active'
+ WHEN NOW() > ${biddings.submissionEndDate}
+ THEN 'closed'
+ ELSE 'unknown'
+ END
+ `.as('submission_status'),
+
+ // 마감까지 남은 일수
+ daysUntilDeadline: sql<number>`
+ CASE
+ WHEN ${biddings.submissionEndDate} IS NOT NULL
+ AND NOW() < ${biddings.submissionEndDate}
+ THEN EXTRACT(DAYS FROM (${biddings.submissionEndDate} - NOW()))::integer
+ ELSE NULL
+ END
+ `.as('days_until_deadline'),
+
+ // 시작까지 남은 일수
+ daysUntilStart: sql<number>`
+ CASE
+ WHEN ${biddings.submissionStartDate} IS NOT NULL
+ AND NOW() < ${biddings.submissionStartDate}
+ THEN EXTRACT(DAYS FROM (${biddings.submissionStartDate} - NOW()))::integer
+ ELSE NULL
+ END
+ `.as('days_until_start'),
+
+ // 예산 대비 최저 견적 비율
+ budgetEfficiencyRate: sql<number>`
+ CASE
+ WHEN ${biddings.budget} IS NOT NULL AND ${biddings.budget} > 0
+ AND (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) IS NOT NULL
+ THEN ROUND(
+ (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) / ${biddings.budget} * 100, 1
+ )
+ ELSE NULL
+ END
+ `.as('budget_efficiency_rate'),
+
+ // 내정가 대비 최저 견적 비율
+ targetPriceEfficiencyRate: sql<number>`
+ CASE
+ WHEN ${biddings.targetPrice} IS NOT NULL AND ${biddings.targetPrice} > 0
+ AND (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) IS NOT NULL
+ THEN ROUND(
+ (
+ SELECT MIN(final_quote_amount)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND final_quote_amount IS NOT NULL
+ ) / ${biddings.targetPrice} * 100, 1
+ )
+ ELSE NULL
+ END
+ `.as('target_price_efficiency_rate'),
+
+ // 입찰 진행 단계 점수 (0-100)
+ progressScore: sql<number>`
+ CASE ${biddings.status}
+ WHEN 'bidding_generated' THEN 10
+ WHEN 'request_for_quotation' THEN 20
+ WHEN 'received_quotation' THEN 40
+ WHEN 'set_target_price' THEN 60
+ WHEN 'bidding_opened' THEN 70
+ WHEN 'bidding_closed' THEN 80
+ WHEN 'evaluation_of_bidding' THEN 90
+ WHEN 'vendor_selected' THEN 100
+ WHEN 'bidding_disposal' THEN 0
+ ELSE 0
+ END
+ `.as('progress_score'),
+
+ // 마지막 활동일 (가장 최근 업체 응답일)
+ lastActivityDate: sql<Date>`
+ GREATEST(
+ ${biddings.updatedAt},
+ COALESCE((
+ SELECT MAX(updated_at)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ), ${biddings.updatedAt})
+ )
+ `.as('last_activity_date'),
+ })
+ .from(biddings)
+ .leftJoin(
+ specificationMeetings,
+ sql`${biddings.id} = ${specificationMeetings.biddingId}`
+ )
.where(finalWhere)
.orderBy(...orderByColumns)
.limit(input.perPage)
@@ -305,80 +845,6 @@ export async function getBiddingStatusCounts() {
}
}
-// 입찰유형별 개수 집계
-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 {
// 사양설명회 정보 (선택사항)
@@ -401,17 +867,56 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
prItems?: Array<{
id: string
prNumber: string
- itemCode: string
- itemInfo: string
+ projectId?: number
+ projectInfo?: string
+ shi?: string
quantity: string
quantityUnit: string
totalWeight: string
weightUnit: string
+ materialGroupNumber: string
+ materialGroupInfo: string
+ materialNumber?: string
+ materialInfo?: string
materialDescription: string
hasSpecDocument: boolean
requestedDeliveryDate: string
specFiles: File[]
isRepresentative: boolean
+
+ // 가격 정보
+ annualUnitPrice?: string
+ currency?: string
+
+ // 단위 정보
+ priceUnit?: string
+ purchaseUnit?: string
+ materialWeight?: string
+
+ // WBS 정보
+ wbsCode?: string
+ wbsName?: string
+
+ // Cost Center 정보
+ costCenterCode?: string
+ costCenterName?: string
+
+ // GL Account 정보
+ glAccountCode?: string
+ glAccountName?: string
+
+ // 내정 정보
+ targetUnitPrice?: string
+ targetAmount?: string
+ targetCurrency?: string
+
+ // 예산 정보
+ budgetAmount?: string
+ budgetCurrency?: string
+
+ // 실적 정보
+ actualAmount?: string
+ actualCurrency?: string
}>
// 입찰 조건 (선택사항)
@@ -419,9 +924,10 @@ export interface CreateBiddingInput extends CreateBiddingSchema {
paymentTerms: string
taxConditions: string
incoterms: string
- contractDeliveryDate: string
- shippingPort: string
- destinationPort: string
+ incotermsOption?: string
+ contractDeliveryDate?: string
+ shippingPort?: string
+ destinationPort?: string
isPriceAdjustmentApplicable: boolean
sparePartOptions: string
}
@@ -435,12 +941,54 @@ export interface UpdateBiddingInput extends UpdateBiddingSchema {
id: number
}
+// 4자리 시퀀스 생성 (0001 -> 0009 -> 000A -> 000Z -> 0011 -> ...)
+function generateNextSequence(currentMax: string | null): string {
+ if (!currentMax) {
+ return '0001'; // 첫 번째 시퀀스
+ }
+
+ // 36진수로 변환 (0-9, A-Z)
+ const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+ // 4자리 시퀀스를 36진수로 해석
+ let value = 0;
+ for (let i = 0; i < 4; i++) {
+ const charIndex = chars.indexOf(currentMax[i]);
+ if (charIndex === -1) return '0001'; // 잘못된 문자면 초기화
+ value = value * 36 + charIndex;
+ }
+
+ // 1 증가
+ value += 1;
+
+ // 다시 4자리 36진수로 변환
+ let result = '';
+ for (let i = 0; i < 4; i++) {
+ const remainder = value % 36;
+ result = chars[remainder] + result;
+ value = Math.floor(value / 36);
+ }
+
+ // 4자리가 되도록 앞에 0 채우기
+ return result.padStart(4, '0');
+}
+
// 자동 입찰번호 생성
export async function generateBiddingNumber(
+ contractType: string,
userId?: string,
tx?: any,
maxRetries: number = 5
): Promise<string> {
+ // 계약 타입별 접두사 설정
+ const typePrefix = {
+ 'general': 'E',
+ 'unit_price': 'F',
+ 'sale': 'G'
+ };
+
+ const prefix = typePrefix[contractType as keyof typeof typePrefix] || 'E';
+
// user 테이블의 user.userCode가 있으면 발주담당자 코드로 사용
// userId가 주어졌을 때 user.userCode를 조회, 없으면 '000' 사용
let purchaseManagerCode = '000';
@@ -458,27 +1006,25 @@ export async function generateBiddingNumber(
? purchaseManagerCode.substring(0, 3).toUpperCase()
: '000';
+ // 현재 년도 2자리
+ const currentYear = new Date().getFullYear().toString().slice(-2);
+
const dbInstance = tx || db;
- const prefix = `B${managerCode}`;
+ const yearPrefix = `${prefix}${managerCode}${currentYear}`;
for (let attempt = 0; attempt < maxRetries; attempt++) {
- // 현재 최대 일련번호 조회
+ // 현재 최대 시퀀스 조회 (년도별로, -01 제외하고 앞부분만)
+ const prefixLength = yearPrefix.length + 4;
const result = await dbInstance
- .select({
- maxNumber: sql<string>`MAX(${biddings.biddingNumber})`
+ .select({
+ maxNumber: sql<string>`MAX(LEFT(${biddings.biddingNumber}, ${prefixLength}))`
})
.from(biddings)
- .where(like(biddings.biddingNumber, `${prefix}%`));
+ .where(like(biddings.biddingNumber, `${yearPrefix}%`));
- let sequence = 1;
- if (result[0]?.maxNumber) {
- const lastSequence = parseInt(result[0].maxNumber.slice(-5));
- if (!isNaN(lastSequence)) {
- sequence = lastSequence + 1;
- }
- }
+ const nextSequence = generateNextSequence(result[0]?.maxNumber?.slice(-4) || null);
- const biddingNumber = `${prefix}${sequence.toString().padStart(5, '0')}`;
+ const biddingNumber = `${yearPrefix}${nextSequence}-01`;
// 중복 확인
const existing = await dbInstance
@@ -503,32 +1049,27 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
const userName = await getUserNameById(userId)
return await db.transaction(async (tx) => {
// 자동 입찰번호 생성
- const biddingNumber = await generateBiddingNumber(userId)
+ const biddingNumber = await generateBiddingNumber(input.contractType, userId, tx)
- // 프로젝트 정보 조회
+ // 프로젝트 정보 조회 (PR 아이템에서 설정됨)
let projectName = input.projectName
- if (input.projectId) {
- 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})`
- }
- }
- // 표준 공고문 템플릿 가져오기
+ // 표준 공고문 템플릿 가져오기 (noticeType별)
let standardContent = ''
if (!input.content) {
try {
const template = await tx
.select({ content: biddingNoticeTemplate.content })
.from(biddingNoticeTemplate)
- .where(eq(biddingNoticeTemplate.type, 'standard'))
+ .where(
+ and(
+ eq(biddingNoticeTemplate.isTemplate, true),
+ eq(biddingNoticeTemplate.type, input.noticeType || 'standard')
+ )
+ )
+ .orderBy(desc(biddingNoticeTemplate.updatedAt))
.limit(1)
-
+
if (template.length > 0) {
standardContent = template[0].content
}
@@ -552,22 +1093,26 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
.insert(biddings)
.values({
biddingNumber,
+ originalBiddingNumber: null, // 원입찰번호는 단순 정보이므로 null
revision: input.revision || 0,
- // 프로젝트 정보
- projectId: input.projectId,
+ // 프로젝트 정보 (PR 아이템에서 설정됨)
projectName,
itemName: input.itemName,
title: input.title,
description: input.description,
- content: input.content || standardContent,
contractType: input.contractType,
biddingType: input.biddingType,
awardCount: input.awardCount,
- contractStartDate: input.contractStartDate ? parseDate(input.contractStartDate) : null,
- contractEndDate: input.contractEndDate ? parseDate(input.contractEndDate) : null,
+ contractStartDate: input.contractStartDate ? parseDate(input.contractStartDate) : new Date(),
+ contractEndDate: input.contractEndDate ? parseDate(input.contractEndDate) : (() => {
+ const startDate = input.contractStartDate ? new Date(input.contractStartDate) : new Date()
+ const endDate = new Date(startDate)
+ endDate.setFullYear(endDate.getFullYear() + 1) // 1년 후
+ return endDate
+ })(),
// 자동 등록일 설정
biddingRegistrationDate: new Date(),
@@ -588,9 +1133,17 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
// biddingSourceType: input.biddingSourceType || 'manual',
isPublic: input.isPublic || false,
isUrgent: input.isUrgent || false,
- managerName: input.managerName,
- managerEmail: input.managerEmail,
- managerPhone: input.managerPhone,
+
+ // 구매조직
+ purchasingOrganization: input.purchasingOrganization,
+
+ // 담당자 정보 (user FK)
+ bidPicId: input.bidPicId ? parseInt(input.bidPicId.toString()) : null,
+ bidPicName: input.bidPicName || null,
+ bidPicCode: input.bidPicCode || null,
+ supplyPicId: input.supplyPicId ? parseInt(input.supplyPicId.toString()) : null,
+ supplyPicName: input.supplyPicName || null,
+ supplyPicCode: input.supplyPicCode || null,
remarks: input.remarks,
createdBy: userName,
@@ -600,7 +1153,15 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
const biddingId = newBidding.id
- // 2. 사양설명회 정보 저장 (있는 경우)
+ // 2. 입찰공고 생성 (템플릿에서 복제)
+ await tx.insert(biddingNoticeTemplate).values({
+ biddingId,
+ title: input.title + ' 입찰공고',
+ content: input.content || standardContent,
+ isTemplate: false,
+ })
+
+ // 3. 사양설명회 정보 저장 (있는 경우)
if (input.specificationMeeting) {
const [newSpecMeeting] = await tx
.insert(specificationMeetings)
@@ -666,9 +1227,10 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
paymentTerms: input.biddingConditions.paymentTerms,
taxConditions: input.biddingConditions.taxConditions,
incoterms: input.biddingConditions.incoterms,
+ incotermsOption: input.biddingConditions.incotermsOption,
contractDeliveryDate: input.biddingConditions.contractDeliveryDate || null,
- shippingPort: input.biddingConditions.shippingPort,
- destinationPort: input.biddingConditions.destinationPort,
+ shippingPort: input.biddingConditions.shippingPort || null,
+ destinationPort: input.biddingConditions.destinationPort || null,
isPriceAdjustmentApplicable: input.biddingConditions.isPriceAdjustmentApplicable,
sparePartOptions: input.biddingConditions.sparePartOptions,
})
@@ -684,18 +1246,61 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
// PR 아이템 저장
const [newPrItem] = await tx.insert(prItemsForBidding).values({
biddingId,
- itemNumber: prItem.itemCode, // itemCode를 itemNumber로 매핑
- projectInfo: '', // 필요시 추가
- itemInfo: prItem.itemInfo,
- shi: '', // 필요시 추가
+ projectId: prItem.projectId, // 프로젝트 ID 추가
+ projectInfo: prItem.projectInfo || '', // 프로젝트 정보 (기존 호환성 유지)
+ shi: prItem.shi || '', // SHI 정보
requestedDeliveryDate: prItem.requestedDeliveryDate ? new Date(prItem.requestedDeliveryDate) : null,
- annualUnitPrice: null, // 필요시 추가
- currency: 'KRW', // 기본값 또는 입력받은 값
+
+ // 자재 그룹 정보 (필수)
+ materialGroupNumber: prItem.materialGroupNumber,
+ materialGroupInfo: prItem.materialGroupInfo,
+
+ // 자재 정보
+ materialNumber: prItem.materialNumber || null,
+ materialInfo: prItem.materialInfo || null,
+
+ // 가격 정보
+ annualUnitPrice: prItem.annualUnitPrice ? parseFloat(prItem.annualUnitPrice) : null,
+ currency: prItem.currency || 'KRW',
+
+ // 수량 및 중량
quantity: prItem.quantity ? parseFloat(prItem.quantity) : null,
- quantityUnit: prItem.quantityUnit as any, // enum 타입에 맞게
+ quantityUnit: prItem.quantityUnit as any,
totalWeight: prItem.totalWeight ? parseFloat(prItem.totalWeight) : null,
- weightUnit: prItem.weightUnit as any, // enum 타입에 맞게
- materialDescription: '', // 필요시 추가
+ weightUnit: prItem.weightUnit as any,
+
+ // 단위 정보
+ priceUnit: prItem.priceUnit || null,
+ purchaseUnit: prItem.purchaseUnit || null,
+ materialWeight: prItem.materialWeight ? parseFloat(prItem.materialWeight) : null,
+
+ // WBS 정보
+ wbsCode: prItem.wbsCode || null,
+ wbsName: prItem.wbsName || null,
+
+ // Cost Center 정보
+ costCenterCode: prItem.costCenterCode || null,
+ costCenterName: prItem.costCenterName || null,
+
+ // GL Account 정보
+ glAccountCode: prItem.glAccountCode || null,
+ glAccountName: prItem.glAccountName || null,
+
+ // 내정 정보
+ targetUnitPrice: prItem.targetUnitPrice ? parseFloat(prItem.targetUnitPrice) : null,
+ targetAmount: prItem.targetAmount ? parseFloat(prItem.targetAmount) : null,
+ targetCurrency: prItem.targetCurrency || 'KRW',
+
+ // 예산 정보
+ budgetAmount: prItem.budgetAmount ? parseFloat(prItem.budgetAmount) : null,
+ budgetCurrency: prItem.budgetCurrency || 'KRW',
+
+ // 실적 정보
+ actualAmount: prItem.actualAmount ? parseFloat(prItem.actualAmount) : null,
+ actualCurrency: prItem.actualCurrency || 'KRW',
+
+ // 상세 정보
+ materialDescription: prItem.materialDescription || '',
prNumber: prItem.prNumber,
hasSpecDocument: prItem.specFiles.length > 0,
isRepresentative: prItem.isRepresentative,
@@ -724,7 +1329,7 @@ export async function createBidding(input: CreateBiddingInput, userId: string) {
mimeType: file.type,
filePath: saveResult.publicPath!,
// publicPath: saveResult.publicPath,
- title: `${prItem.itemInfo || prItem.itemCode} 스펙 - ${file.name}`,
+ title: `${prItem.materialGroupInfo || prItem.materialGroupNumber} 스펙 - ${file.name}`,
description: `PR ${prItem.prNumber}의 스펙 문서`,
isPublic: false,
isRequired: false,
@@ -840,9 +1445,15 @@ export async function updateBidding(input: UpdateBiddingInput, userId: string) {
if (input.status !== undefined) updateData.status = input.status
if (input.isPublic !== undefined) updateData.isPublic = input.isPublic
if (input.isUrgent !== undefined) updateData.isUrgent = input.isUrgent
- 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.purchasingOrganization !== undefined) updateData.purchasingOrganization = input.purchasingOrganization
+
+ // 담당자 정보 (user FK)
+ if (input.bidPicId !== undefined) updateData.bidPicId = input.bidPicId
+ if (input.bidPicName !== undefined) updateData.bidPicName = input.bidPicName
+ if (input.supplyPicId !== undefined) updateData.supplyPicId = input.supplyPicId
+ if (input.supplyPicName !== undefined) updateData.supplyPicName = input.supplyPicName
if (input.remarks !== undefined) updateData.remarks = input.remarks
@@ -908,6 +1519,12 @@ export async function deleteBidding(id: number) {
// 단일 입찰 조회
export async function getBiddingById(id: number) {
try {
+ // ID 유효성 검증
+ if (!id || isNaN(id) || id <= 0) {
+ console.warn('Invalid bidding ID provided to getBiddingById:', id)
+ return null
+ }
+
const bidding = await db
.select()
.from(biddings)
@@ -979,17 +1596,52 @@ export interface PRDetails {
}>
items: Array<{
id: number
- itemNumber?: string | null
- itemInfo: string
- quantity?: number | null
- quantityUnit?: string | null
- requestedDeliveryDate?: string | null
- prNumber?: string | null
- annualUnitPrice?: number | null
- currency: string
- totalWeight?: number | null
- weightUnit?: string | null
- materialDescription?: string | null
+ itemNumber: string | null
+ prNumber: string | null
+ projectInfo: string | null
+ shi: string | null
+ // 자재 그룹 정보
+ materialGroupNumber: string | null
+ materialGroupInfo: string | null
+ // 자재 정보
+ materialNumber: string | null
+ materialInfo: string | null
+ // 품목 정보
+ itemInfo: string | null
+ // 수량 및 중량
+ quantity: number | null
+ quantityUnit: string | null
+ totalWeight: number | null
+ weightUnit: string | null
+ // 가격 정보
+ annualUnitPrice: number | null
+ currency: string | null
+ // 단위 정보
+ priceUnit: string | null
+ purchaseUnit: string | null
+ materialWeight: number | null
+ // WBS 정보
+ wbsCode: string | null
+ wbsName: string | null
+ // Cost Center 정보
+ costCenterCode: string | null
+ costCenterName: string | null
+ // GL Account 정보
+ glAccountCode: string | null
+ glAccountName: string | null
+ // 내정 정보
+ targetUnitPrice: number | null
+ targetAmount: number | null
+ targetCurrency: string | null
+ // 예산 정보
+ budgetAmount: number | null
+ budgetCurrency: string | null
+ // 실적 정보
+ actualAmount: number | null
+ actualCurrency: string | null
+ // 납품 일정
+ requestedDeliveryDate: string | null
+ // SPEC 문서
hasSpecDocument: boolean
createdAt: string
updatedAt: string
@@ -1000,7 +1652,7 @@ export interface PRDetails {
fileSize: number
filePath: string
uploadedAt: string
- title?: string | null
+ title: string | null
}>
}>
}
@@ -1162,21 +1814,56 @@ export async function getPRDetailsAction(
)
)
- // 5. 데이터 직렬화
+ // 5. 데이터 직렬화 (모든 필드 포함)
return {
id: item.id,
itemNumber: item.itemNumber,
+ prNumber: item.prNumber,
+ projectInfo: item.projectInfo,
+ shi: item.shi,
+ // 자재 그룹 정보
+ materialGroupNumber: item.materialGroupNumber,
+ materialGroupInfo: item.materialGroupInfo,
+ // 자재 정보
+ materialNumber: item.materialNumber,
+ materialInfo: item.materialInfo,
+ // 품목 정보
itemInfo: item.itemInfo,
+ // 수량 및 중량
quantity: item.quantity ? Number(item.quantity) : null,
quantityUnit: item.quantityUnit,
- requestedDeliveryDate: item.requestedDeliveryDate || null,
- prNumber: item.prNumber,
- annualUnitPrice: item.annualUnitPrice ? Number(item.annualUnitPrice) : null,
- currency: item.currency,
totalWeight: item.totalWeight ? Number(item.totalWeight) : null,
weightUnit: item.weightUnit,
- materialDescription: item.materialDescription,
- hasSpecDocument: item.hasSpecDocument,
+ // 가격 정보
+ annualUnitPrice: item.annualUnitPrice ? Number(item.annualUnitPrice) : null,
+ currency: item.currency,
+ // 단위 정보
+ priceUnit: item.priceUnit,
+ purchaseUnit: item.purchaseUnit,
+ materialWeight: item.materialWeight ? Number(item.materialWeight) : null,
+ // WBS 정보
+ wbsCode: item.wbsCode,
+ wbsName: item.wbsName,
+ // Cost Center 정보
+ costCenterCode: item.costCenterCode,
+ costCenterName: item.costCenterName,
+ // GL Account 정보
+ glAccountCode: item.glAccountCode,
+ glAccountName: item.glAccountName,
+ // 내정 정보
+ targetUnitPrice: item.targetUnitPrice ? Number(item.targetUnitPrice) : null,
+ targetAmount: item.targetAmount ? Number(item.targetAmount) : null,
+ targetCurrency: item.targetCurrency,
+ // 예산 정보
+ budgetAmount: item.budgetAmount ? Number(item.budgetAmount) : null,
+ budgetCurrency: item.budgetCurrency,
+ // 실적 정보
+ actualAmount: item.actualAmount ? Number(item.actualAmount) : null,
+ actualCurrency: item.actualCurrency,
+ // 납품 일정
+ requestedDeliveryDate: item.requestedDeliveryDate || null,
+ // 기타
+ hasSpecDocument: item.hasSpecDocument || false,
createdAt: item.createdAt?.toISOString() || '',
updatedAt: item.updatedAt?.toISOString() || '',
specDocuments: specDocuments.map(doc => ({
@@ -1302,12 +1989,688 @@ export async function getBiddingConditions(biddingId: number) {
}
// 입찰 조건 업데이트
+// === 입찰 관리 서버 액션들 ===
+
+// 입찰 기본 정보 업데이트 (관리 페이지용)
+export async function updateBiddingBasicInfo(
+ biddingId: number,
+ updates: {
+ title?: string
+ description?: string
+ content?: string
+ noticeType?: string
+ contractType?: string
+ biddingType?: string
+ biddingTypeCustom?: string
+ awardCount?: string
+ budget?: string
+ finalBidPrice?: string
+ targetPrice?: string
+ prNumber?: string
+ contractStartDate?: string
+ contractEndDate?: string
+ submissionStartDate?: string
+ submissionEndDate?: string
+ evaluationDate?: string
+ hasSpecificationMeeting?: boolean
+ hasPrDocument?: boolean
+ currency?: string
+ purchasingOrganization?: string
+ bidPicName?: string
+ bidPicCode?: string
+ supplyPicName?: string
+ supplyPicCode?: string
+ requesterName?: string
+ remarks?: string
+ },
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ // 존재 여부 확인
+ const existing = await db
+ .select({ id: biddings.id })
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (existing.length === 0) {
+ return {
+ success: false,
+ error: '존재하지 않는 입찰입니다.'
+ }
+ }
+
+ // 날짜 문자열을 Date 객체로 변환
+ const parseDate = (dateStr?: string) => {
+ if (!dateStr) return undefined
+ try {
+ return new Date(dateStr)
+ } catch {
+ return undefined
+ }
+ }
+
+ // 숫자 문자열을 숫자로 변환 (빈 문자열은 null)
+ const parseNumeric = (value?: string): number | null | undefined => {
+ if (value === undefined) return undefined
+ if (value === '' || value === null) return null
+ const parsed = parseFloat(value)
+ return isNaN(parsed) ? null : parsed
+ }
+
+ // 업데이트할 데이터 준비
+ const updateData: any = {
+ updatedAt: new Date(),
+ updatedBy: userName,
+ }
+
+ // 정의된 필드들만 업데이트
+ if (updates.title !== undefined) updateData.title = updates.title
+ if (updates.description !== undefined) updateData.description = updates.description
+ if (updates.content !== undefined) updateData.content = updates.content
+ if (updates.noticeType !== undefined) updateData.noticeType = updates.noticeType
+ if (updates.contractType !== undefined) updateData.contractType = updates.contractType
+ if (updates.biddingType !== undefined) updateData.biddingType = updates.biddingType
+ if (updates.biddingTypeCustom !== undefined) updateData.biddingTypeCustom = updates.biddingTypeCustom
+ if (updates.awardCount !== undefined) updateData.awardCount = updates.awardCount
+ if (updates.budget !== undefined) updateData.budget = parseNumeric(updates.budget)
+ if (updates.finalBidPrice !== undefined) updateData.finalBidPrice = parseNumeric(updates.finalBidPrice)
+ if (updates.targetPrice !== undefined) updateData.targetPrice = parseNumeric(updates.targetPrice)
+ if (updates.prNumber !== undefined) updateData.prNumber = updates.prNumber
+ if (updates.contractStartDate !== undefined) updateData.contractStartDate = parseDate(updates.contractStartDate)
+ if (updates.contractEndDate !== undefined) updateData.contractEndDate = parseDate(updates.contractEndDate)
+ if (updates.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(updates.submissionStartDate)
+ if (updates.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(updates.submissionEndDate)
+ if (updates.evaluationDate !== undefined) updateData.evaluationDate = parseDate(updates.evaluationDate)
+ if (updates.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = updates.hasSpecificationMeeting
+ if (updates.hasPrDocument !== undefined) updateData.hasPrDocument = updates.hasPrDocument
+ if (updates.currency !== undefined) updateData.currency = updates.currency
+ if (updates.purchasingOrganization !== undefined) updateData.purchasingOrganization = updates.purchasingOrganization
+ if (updates.bidPicName !== undefined) updateData.bidPicName = updates.bidPicName
+ if (updates.bidPicCode !== undefined) updateData.bidPicCode = updates.bidPicCode
+ if (updates.supplyPicName !== undefined) updateData.supplyPicName = updates.supplyPicName
+ if (updates.supplyPicCode !== undefined) updateData.supplyPicCode = updates.supplyPicCode
+ if (updates.requesterName !== undefined) updateData.requesterName = updates.requesterName
+ if (updates.remarks !== undefined) updateData.remarks = updates.remarks
+
+ // 데이터베이스 업데이트
+ await db
+ .update(biddings)
+ .set(updateData)
+ .where(eq(biddings.id, biddingId))
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ revalidatePath(`/evcp/bid/${biddingId}/info`)
+
+ return {
+ success: true,
+ message: '입찰 기본 정보가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update bidding basic info:', error)
+ return {
+ success: false,
+ error: '입찰 기본 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 일정 업데이트
+export async function updateBiddingSchedule(
+ biddingId: number,
+ schedule: {
+ submissionStartDate?: string
+ submissionEndDate?: string
+ remarks?: string
+ isUrgent?: boolean
+ hasSpecificationMeeting?: boolean
+ },
+ userId: string,
+ specificationMeeting?: {
+ meetingDate: string
+ meetingTime: string
+ location: string
+ address: string
+ contactPerson: string
+ contactPhone: string
+ contactEmail: string
+ agenda: string
+ materials: string
+ notes: string
+ isRequired: boolean
+ }
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ // 날짜 문자열을 Date 객체로 변환
+ const parseDate = (dateStr?: string) => {
+ if (!dateStr) return undefined
+ try {
+ return new Date(dateStr)
+ } catch {
+ return undefined
+ }
+ }
+
+ return await db.transaction(async (tx) => {
+ const updateData: any = {
+ updatedAt: new Date(),
+ updatedBy: userName,
+ }
+
+ if (schedule.submissionStartDate !== undefined) updateData.submissionStartDate = parseDate(schedule.submissionStartDate)
+ if (schedule.submissionEndDate !== undefined) updateData.submissionEndDate = parseDate(schedule.submissionEndDate)
+ if (schedule.remarks !== undefined) updateData.remarks = schedule.remarks
+ if (schedule.isUrgent !== undefined) updateData.isUrgent = schedule.isUrgent
+ if (schedule.hasSpecificationMeeting !== undefined) updateData.hasSpecificationMeeting = schedule.hasSpecificationMeeting
+
+ await tx
+ .update(biddings)
+ .set(updateData)
+ .where(eq(biddings.id, biddingId))
+
+ // 사양설명회 정보 저장/업데이트
+ if (schedule.hasSpecificationMeeting && specificationMeeting) {
+ // 기존 사양설명회 정보 확인
+ const existingMeeting = await tx
+ .select()
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ .limit(1)
+
+ if (existingMeeting.length > 0) {
+ // 기존 정보 업데이트
+ await tx
+ .update(specificationMeetings)
+ .set({
+ meetingDate: new Date(specificationMeeting.meetingDate),
+ meetingTime: specificationMeeting.meetingTime || null,
+ location: specificationMeeting.location,
+ address: specificationMeeting.address || null,
+ contactPerson: specificationMeeting.contactPerson,
+ contactPhone: specificationMeeting.contactPhone || null,
+ contactEmail: specificationMeeting.contactEmail || null,
+ agenda: specificationMeeting.agenda || null,
+ materials: specificationMeeting.materials || null,
+ notes: specificationMeeting.notes || null,
+ isRequired: specificationMeeting.isRequired || false,
+ updatedAt: new Date(),
+ })
+ .where(eq(specificationMeetings.id, existingMeeting[0].id))
+ } else {
+ // 새로 생성
+ await tx
+ .insert(specificationMeetings)
+ .values({
+ biddingId,
+ meetingDate: new Date(specificationMeeting.meetingDate),
+ meetingTime: specificationMeeting.meetingTime || null,
+ location: specificationMeeting.location,
+ address: specificationMeeting.address || null,
+ contactPerson: specificationMeeting.contactPerson,
+ contactPhone: specificationMeeting.contactPhone || null,
+ contactEmail: specificationMeeting.contactEmail || null,
+ agenda: specificationMeeting.agenda || null,
+ materials: specificationMeeting.materials || null,
+ notes: specificationMeeting.notes || null,
+ isRequired: specificationMeeting.isRequired || false,
+ })
+ }
+ } else if (!schedule.hasSpecificationMeeting) {
+ // 사양설명회 실시 여부가 false로 변경된 경우, 관련 정보 삭제
+ await tx
+ .delete(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ }
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ revalidatePath(`/evcp/bid/${biddingId}/schedule`)
+
+ return {
+ success: true,
+ message: '입찰 일정이 성공적으로 업데이트되었습니다.'
+ }
+ })
+ } catch (error) {
+ console.error('Failed to update bidding schedule:', error)
+ return {
+ success: false,
+ error: '입찰 일정 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 품목 관리 액션들
+export async function getBiddingItems(biddingId: number) {
+ try {
+ // PR 아이템 조회 (실제로는 prItemsForBidding 테이블에서 조회)
+ const items = await db
+ .select({
+ id: prItemsForBidding.id,
+ itemName: prItemsForBidding.itemName,
+ description: prItemsForBidding.description,
+ quantity: prItemsForBidding.quantity,
+ unit: prItemsForBidding.unit,
+ unitPrice: prItemsForBidding.unitPrice,
+ totalPrice: prItemsForBidding.totalPrice,
+ currency: prItemsForBidding.currency,
+ })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+ .orderBy(prItemsForBidding.id)
+
+ return {
+ success: true,
+ data: items
+ }
+ } catch (error) {
+ console.error('Failed to get bidding items:', error)
+ return {
+ success: false,
+ error: '품목 정보를 불러오는데 실패했습니다.'
+ }
+ }
+}
+
+export async function updateBiddingItem(
+ itemId: number,
+ updates: {
+ itemName?: string
+ description?: string
+ quantity?: number
+ unit?: string
+ unitPrice?: number
+ currency?: string
+ }
+) {
+ try {
+ const updateData: any = {
+ updatedAt: new Date(),
+ }
+
+ if (updates.itemName !== undefined) updateData.itemName = updates.itemName
+ if (updates.description !== undefined) updateData.description = updates.description
+ if (updates.quantity !== undefined) updateData.quantity = updates.quantity
+ if (updates.unit !== undefined) updateData.unit = updates.unit
+ if (updates.unitPrice !== undefined) updateData.unitPrice = updates.unitPrice
+ if (updates.currency !== undefined) updateData.currency = updates.currency
+
+ // 총액 자동 계산
+ if (updates.quantity !== undefined || updates.unitPrice !== undefined) {
+ const item = await db
+ .select({ quantity: prItemsForBidding.quantity, unitPrice: prItemsForBidding.unitPrice })
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.id, itemId))
+ .limit(1)
+
+ if (item.length > 0) {
+ const quantity = updates.quantity ?? item[0].quantity ?? 0
+ const unitPrice = updates.unitPrice ?? item[0].unitPrice ?? 0
+ updateData.totalPrice = quantity * unitPrice
+ }
+ }
+
+ await db
+ .update(prItemsForBidding)
+ .set(updateData)
+ .where(eq(prItemsForBidding.id, itemId))
+
+ return {
+ success: true,
+ message: '품목 정보가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update bidding item:', error)
+ return {
+ success: false,
+ error: '품목 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+export async function addBiddingItem(
+ biddingId: number,
+ item: {
+ itemName: string
+ description?: string
+ quantity?: number
+ unit?: string
+ unitPrice?: number
+ currency?: string
+ }
+) {
+ try {
+ const totalPrice = (item.quantity || 0) * (item.unitPrice || 0)
+
+ await db.insert(prItemsForBidding).values({
+ biddingId,
+ itemName: item.itemName,
+ description: item.description,
+ quantity: item.quantity || 0,
+ unit: item.unit,
+ unitPrice: item.unitPrice || 0,
+ totalPrice,
+ currency: item.currency || 'KRW',
+ })
+
+ return {
+ success: true,
+ message: '품목이 성공적으로 추가되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to add bidding item:', error)
+ return {
+ success: false,
+ error: '품목 추가에 실패했습니다.'
+ }
+ }
+}
+
+export async function removeBiddingItem(itemId: number) {
+ try {
+ await db
+ .delete(prItemsForBidding)
+ .where(eq(prItemsForBidding.id, itemId))
+
+ return {
+ success: true,
+ message: '품목이 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to remove bidding item:', error)
+ return {
+ success: false,
+ error: '품목 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// PR 아이템 추가 (전체 필드 지원)
+export async function addPRItemForBidding(
+ biddingId: number,
+ item: {
+ projectId?: number | null
+ projectInfo?: string | null
+ shi?: string | null
+ materialGroupNumber?: string | null
+ materialGroupInfo?: string | null
+ materialNumber?: string | null
+ materialInfo?: string | null
+ quantity?: string | null
+ quantityUnit?: string | null
+ totalWeight?: string | null
+ weightUnit?: string | null
+ priceUnit?: string | null
+ purchaseUnit?: string | null
+ materialWeight?: string | null
+ wbsCode?: string | null
+ wbsName?: string | null
+ costCenterCode?: string | null
+ costCenterName?: string | null
+ glAccountCode?: string | null
+ glAccountName?: string | null
+ targetUnitPrice?: string | null
+ targetAmount?: string | null
+ targetCurrency?: string | null
+ budgetAmount?: string | null
+ budgetCurrency?: string | null
+ actualAmount?: string | null
+ actualCurrency?: string | null
+ requestedDeliveryDate?: string | null
+ prNumber?: string | null
+ currency?: string | null
+ annualUnitPrice?: string | null
+ hasSpecDocument?: boolean
+ }
+) {
+ try {
+ const result = await db.insert(prItemsForBidding).values({
+ biddingId,
+ projectId: item.projectId || null,
+ projectInfo: item.projectInfo || null,
+ shi: item.shi || null,
+ materialGroupNumber: item.materialGroupNumber || null,
+ materialGroupInfo: item.materialGroupInfo || null,
+ materialNumber: item.materialNumber || null,
+ materialInfo: item.materialInfo || null,
+ quantity: item.quantity ? parseFloat(item.quantity) : null,
+ quantityUnit: item.quantityUnit || null,
+ totalWeight: item.totalWeight ? parseFloat(item.totalWeight) : null,
+ weightUnit: item.weightUnit || null,
+ priceUnit: item.priceUnit || null,
+ purchaseUnit: item.purchaseUnit || null,
+ materialWeight: item.materialWeight ? parseFloat(item.materialWeight) : null,
+ wbsCode: item.wbsCode || null,
+ wbsName: item.wbsName || null,
+ costCenterCode: item.costCenterCode || null,
+ costCenterName: item.costCenterName || null,
+ glAccountCode: item.glAccountCode || null,
+ glAccountName: item.glAccountName || null,
+ targetUnitPrice: item.targetUnitPrice ? parseFloat(item.targetUnitPrice) : null,
+ targetAmount: item.targetAmount ? parseFloat(item.targetAmount) : null,
+ targetCurrency: item.targetCurrency || 'KRW',
+ budgetAmount: item.budgetAmount ? parseFloat(item.budgetAmount) : null,
+ budgetCurrency: item.budgetCurrency || 'KRW',
+ actualAmount: item.actualAmount ? parseFloat(item.actualAmount) : null,
+ actualCurrency: item.actualCurrency || 'KRW',
+ requestedDeliveryDate: item.requestedDeliveryDate ? new Date(item.requestedDeliveryDate) : null,
+ prNumber: item.prNumber || null,
+ currency: item.currency || 'KRW',
+ annualUnitPrice: item.annualUnitPrice ? parseFloat(item.annualUnitPrice) : null,
+ hasSpecDocument: item.hasSpecDocument || false,
+ }).returning()
+
+ revalidatePath(`/evcp/bid/${biddingId}/info`)
+ revalidatePath(`/evcp/bid/${biddingId}`)
+
+ return {
+ success: true,
+ data: result[0],
+ message: '품목이 성공적으로 추가되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to add PR item:', error)
+ return {
+ success: false,
+ error: '품목 추가에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 업체 관리 액션들
+export async function getBiddingVendors(biddingId: number) {
+ try {
+ const vendorsData = await db
+ .select({
+ id: biddingCompanies.id,
+ companyId: biddingCompanies.companyId, // 벤더 ID 추가
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ contactPerson: biddingCompanies.contactPerson,
+ contactEmail: biddingCompanies.contactEmail,
+ contactPhone: biddingCompanies.contactPhone,
+ quotationAmount: biddingCompanies.finalQuoteAmount,
+ currency: sql<string>`'KRW'`,
+ invitationStatus: biddingCompanies.invitationStatus,
+ isPriceAdjustmentApplicableQuestion: biddingCompanies.isPriceAdjustmentApplicableQuestion,
+ })
+ .from(biddingCompanies)
+ .leftJoin(vendors, eq(biddingCompanies.companyId, vendors.id))
+ .where(eq(biddingCompanies.biddingId, biddingId))
+ .orderBy(biddingCompanies.id)
+
+ return {
+ success: true,
+ data: vendorsData
+ }
+ } catch (error) {
+ console.error('Failed to get bidding vendors:', error)
+ return {
+ success: false,
+ error: '업체 정보를 불러오는데 실패했습니다.'
+ }
+ }
+}
+
+export async function updateBiddingCompanyPriceAdjustmentQuestion(
+ biddingCompanyId: number,
+ isPriceAdjustmentApplicableQuestion: boolean
+) {
+ try {
+ await db
+ .update(biddingCompanies)
+ .set({
+ isPriceAdjustmentApplicableQuestion,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ return {
+ success: true,
+ message: '연동제 적용요건 문의 여부가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update price adjustment question:', error)
+ return {
+ success: false,
+ error: '연동제 적용요건 문의 여부 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+export async function updateVendorContact(
+ biddingCompanyId: number,
+ contact: {
+ contactPerson?: string
+ contactEmail?: string
+ contactPhone?: string
+ }
+) {
+ try {
+ // biddingCompanies 테이블에 연락처 정보가 직접 저장되어 있으므로 직접 업데이트
+ await db
+ .update(biddingCompanies)
+ .set({
+ contactPerson: contact.contactPerson,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone,
+ updatedAt: new Date(),
+ })
+ .where(eq(biddingCompanies.id, biddingCompanyId))
+
+ return {
+ success: true,
+ message: '담당자 정보가 성공적으로 업데이트되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to update vendor contact:', error)
+ return {
+ success: false,
+ error: '담당자 정보 업데이트에 실패했습니다.'
+ }
+ }
+}
+
+// 입찰 참여 업체 담당자 관리 함수들
+export async function getBiddingCompanyContacts(biddingId: number, vendorId: number) {
+ try {
+ const contacts = await db
+ .select({
+ id: biddingCompaniesContacts.id,
+ biddingId: biddingCompaniesContacts.biddingId,
+ vendorId: biddingCompaniesContacts.vendorId,
+ contactName: biddingCompaniesContacts.contactName,
+ contactEmail: biddingCompaniesContacts.contactEmail,
+ contactNumber: biddingCompaniesContacts.contactNumber,
+ createdAt: biddingCompaniesContacts.createdAt,
+ updatedAt: biddingCompaniesContacts.updatedAt,
+ })
+ .from(biddingCompaniesContacts)
+ .where(
+ and(
+ eq(biddingCompaniesContacts.biddingId, biddingId),
+ eq(biddingCompaniesContacts.vendorId, vendorId)
+ )
+ )
+ .orderBy(asc(biddingCompaniesContacts.contactName))
+
+ return {
+ success: true,
+ data: contacts
+ }
+ } catch (error) {
+ console.error('Failed to get bidding company contacts:', error)
+ return {
+ success: false,
+ error: '담당자 목록을 불러오는데 실패했습니다.'
+ }
+ }
+}
+
+export async function createBiddingCompanyContact(
+ biddingId: number,
+ vendorId: number,
+ contact: {
+ contactName: string
+ contactEmail: string
+ contactNumber?: string
+ }
+) {
+ try {
+ const [newContact] = await db
+ .insert(biddingCompaniesContacts)
+ .values({
+ biddingId,
+ vendorId,
+ contactName: contact.contactName,
+ contactEmail: contact.contactEmail,
+ contactNumber: contact.contactNumber || null,
+ })
+ .returning()
+
+ return {
+ success: true,
+ data: newContact,
+ message: '담당자가 성공적으로 추가되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to create bidding company contact:', error)
+ return {
+ success: false,
+ error: '담당자 추가에 실패했습니다.'
+ }
+ }
+}
+
+export async function deleteBiddingCompanyContact(contactId: number) {
+ try {
+ await db
+ .delete(biddingCompaniesContacts)
+ .where(eq(biddingCompaniesContacts.id, contactId))
+
+ return {
+ success: true,
+ message: '담당자가 성공적으로 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete bidding company contact:', error)
+ return {
+ success: false,
+ error: '담당자 삭제에 실패했습니다.'
+ }
+ }
+}
+
export async function updateBiddingConditions(
biddingId: number,
updates: {
paymentTerms?: string
taxConditions?: string
incoterms?: string
+ incotermsOption?: string
contractDeliveryDate?: string
shippingPort?: string
destinationPort?: string
@@ -1328,6 +2691,7 @@ export async function updateBiddingConditions(
paymentTerms: updates.paymentTerms,
taxConditions: updates.taxConditions,
incoterms: updates.incoterms,
+ incotermsOption: updates.incotermsOption,
contractDeliveryDate: updates.contractDeliveryDate || null,
shippingPort: updates.shippingPort,
destinationPort: updates.destinationPort,
@@ -1367,6 +2731,272 @@ export async function updateBiddingConditions(
}
}
+// 사전견적용 일반견적 생성 액션
+export async function createPreQuoteRfqAction(input: {
+ biddingId: number
+ rfqType: string
+ rfqTitle: string
+ dueDate: Date
+ picUserId: number
+ projectId?: number
+ remark?: string
+ items: Array<{
+ itemCode: string
+ itemName: string
+ materialCode?: string
+ materialName?: string
+ quantity: number
+ uom: string
+ remark?: string
+ }>
+ biddingConditions?: {
+ paymentTerms?: string | null
+ taxConditions?: string | null
+ incoterms?: string | null
+ incotermsOption?: string | null
+ contractDeliveryDate?: string | null
+ shippingPort?: string | null
+ destinationPort?: string | null
+ isPriceAdjustmentApplicable?: boolean | null
+ sparePartOptions?: string | null
+ }
+ createdBy: number
+ updatedBy: number
+}) {
+ try {
+ // 일반견적 생성 서버 액션 및 필요한 스키마 import
+ const { createGeneralRfqAction } = await import('@/lib/rfq-last/service')
+ const { rfqLastDetails, rfqLastVendorResponses, rfqLastVendorResponseHistory } = await import('@/db/schema')
+
+ // 일반견적 생성
+ const result = await createGeneralRfqAction({
+ rfqType: input.rfqType,
+ rfqTitle: input.rfqTitle,
+ dueDate: input.dueDate,
+ picUserId: input.picUserId,
+ projectId: input.projectId,
+ remark: input.remark || '',
+ items: input.items.map(item => ({
+ itemCode: item.itemCode,
+ itemName: item.itemName,
+ quantity: item.quantity,
+ uom: item.uom,
+ remark: item.remark,
+ materialCode: item.materialCode,
+ materialName: item.materialName,
+ })),
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ })
+
+ if (!result.success || !result.data) {
+ return {
+ success: false,
+ error: result.error || '사전견적용 일반견적 생성에 실패했습니다',
+ }
+ }
+
+ const rfqId = result.data.id
+ const conditions = input.biddingConditions
+
+ // 입찰 조건을 RFQ 조건으로 매핑
+ const mapBiddingConditionsToRfqConditions = () => {
+ if (!conditions) {
+ return {
+ currency: 'KRW',
+ paymentTermsCode: undefined,
+ incotermsCode: undefined,
+ incotermsDetail: undefined,
+ deliveryDate: undefined,
+ taxCode: undefined,
+ placeOfShipping: undefined,
+ placeOfDestination: undefined,
+ materialPriceRelatedYn: false,
+ sparepartYn: false,
+ sparepartDescription: undefined,
+ }
+ }
+
+ // contractDeliveryDate 문자열을 Date로 변환 (timestamp 타입용)
+ let deliveryDate: Date | undefined = undefined
+ if (conditions.contractDeliveryDate) {
+ try {
+ const date = new Date(conditions.contractDeliveryDate)
+ if (!isNaN(date.getTime())) {
+ deliveryDate = date
+ }
+ } catch (error) {
+ console.warn('Failed to parse contractDeliveryDate:', error)
+ }
+ }
+
+ return {
+ currency: 'KRW', // 기본값
+ paymentTermsCode: conditions.paymentTerms || undefined,
+ incotermsCode: conditions.incoterms || undefined,
+ incotermsDetail: conditions.incotermsOption || undefined,
+ deliveryDate: deliveryDate, // timestamp 타입 (rfqLastDetails용)
+ vendorDeliveryDate: deliveryDate, // date 타입 (rfqLastVendorResponses용)
+ taxCode: conditions.taxConditions || undefined,
+ placeOfShipping: conditions.shippingPort || undefined,
+ placeOfDestination: conditions.destinationPort || undefined,
+ materialPriceRelatedYn: conditions.isPriceAdjustmentApplicable ?? false,
+ sparepartYn: !!conditions.sparePartOptions, // sparePartOptions가 있으면 true
+ sparepartDescription: conditions.sparePartOptions || undefined,
+ }
+ }
+
+ const rfqConditions = mapBiddingConditionsToRfqConditions()
+
+ // 입찰에 참여한 업체 목록 조회
+ const vendorsResult = await getBiddingVendors(input.biddingId)
+ if (!vendorsResult.success || !vendorsResult.data || vendorsResult.data.length === 0) {
+ return {
+ success: true,
+ message: '사전견적용 일반견적이 생성되었습니다. (참여 업체 없음)',
+ data: {
+ rfqCode: result.data.rfqCode,
+ rfqId: result.data.id,
+ },
+ }
+ }
+
+ // 각 업체에 대해 rfqLastDetails와 rfqLastVendorResponses 생성
+ await db.transaction(async (tx) => {
+ for (const vendor of vendorsResult.data) {
+ if (!vendor.companyId) continue
+
+ // 1. rfqLastDetails 생성 (구매자 제시 조건)
+ const [rfqDetail] = await tx
+ .insert(rfqLastDetails)
+ .values({
+ rfqsLastId: rfqId,
+ vendorsId: vendor.companyId,
+ currency: rfqConditions.currency,
+ paymentTermsCode: rfqConditions.paymentTermsCode || null,
+ incotermsCode: rfqConditions.incotermsCode || null,
+ incotermsDetail: rfqConditions.incotermsDetail || null,
+ deliveryDate: rfqConditions.deliveryDate || null,
+ taxCode: rfqConditions.taxCode || null,
+ placeOfShipping: rfqConditions.placeOfShipping || null,
+ placeOfDestination: rfqConditions.placeOfDestination || null,
+ materialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ sparepartYn: rfqConditions.sparepartYn,
+ sparepartDescription: rfqConditions.sparepartDescription || null,
+ updatedBy: input.updatedBy,
+ createdBy: input.createdBy,
+ isLatest: true,
+ })
+ .returning()
+
+ // 2. rfqLastVendorResponses 생성 (초기 응답 레코드)
+ const [vendorResponse] = await tx
+ .insert(rfqLastVendorResponses)
+ .values({
+ rfqsLastId: rfqId,
+ rfqLastDetailsId: rfqDetail.id,
+ vendorId: vendor.companyId,
+ status: '대기중',
+ responseVersion: 1,
+ isLatest: true,
+ participationStatus: '미응답',
+ currency: rfqConditions.currency,
+ // 구매자 제시 조건을 벤더 제안 조건의 초기값으로 복사
+ vendorCurrency: rfqConditions.currency,
+ vendorPaymentTermsCode: rfqConditions.paymentTermsCode || null,
+ vendorIncotermsCode: rfqConditions.incotermsCode || null,
+ vendorIncotermsDetail: rfqConditions.incotermsDetail || null,
+ vendorDeliveryDate: rfqConditions.vendorDeliveryDate || null,
+ vendorTaxCode: rfqConditions.taxCode || null,
+ vendorPlaceOfShipping: rfqConditions.placeOfShipping || null,
+ vendorPlaceOfDestination: rfqConditions.placeOfDestination || null,
+ vendorMaterialPriceRelatedYn: rfqConditions.materialPriceRelatedYn,
+ vendorSparepartYn: rfqConditions.sparepartYn,
+ vendorSparepartDescription: rfqConditions.sparepartDescription || null,
+ createdBy: input.createdBy,
+ updatedBy: input.updatedBy,
+ })
+ .returning()
+
+ // 3. 이력 기록
+ await tx
+ .insert(rfqLastVendorResponseHistory)
+ .values({
+ vendorResponseId: vendorResponse.id,
+ action: '생성',
+ newStatus: '대기중',
+ changeDetails: {
+ action: '사전견적용 일반견적 생성',
+ biddingId: input.biddingId,
+ conditions: rfqConditions,
+ },
+ performedBy: input.createdBy,
+ })
+ }
+ })
+
+ return {
+ success: true,
+ message: `사전견적용 일반견적이 성공적으로 생성되었습니다. (${vendorsResult.data.length}개 업체 추가)`,
+ data: {
+ rfqCode: result.data.rfqCode,
+ rfqId: result.data.id,
+ },
+ }
+ } catch (error) {
+ console.error('Failed to create pre-quote RFQ:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '사전견적용 일반견적 생성에 실패했습니다',
+ }
+ }
+}
+
+// 일반견적 RFQ 코드 미리보기 (rfq-last/service에서 재사용)
+export async function previewGeneralRfqCode(picUserId: number): Promise<string> {
+ try {
+ const { previewGeneralRfqCode: previewCode } = await import('@/lib/rfq-last/service')
+ return await previewCode(picUserId)
+ } catch (error) {
+ console.error('Failed to preview general RFQ code:', error)
+ return 'F???00001'
+ }
+}
+
+// 내정가 산정 기준 업데이트
+export async function updateTargetPriceCalculationCriteria(
+ biddingId: number,
+ criteria: string,
+ userId: string
+) {
+ try {
+ const userName = await getUserNameById(userId)
+
+ await db
+ .update(biddings)
+ .set({
+ targetPriceCalculationCriteria: criteria.trim() || null,
+ updatedAt: new Date(),
+ updatedBy: userName,
+ })
+ .where(eq(biddings.id, biddingId))
+
+ revalidatePath(`/evcp/bid/${biddingId}`)
+ revalidatePath(`/evcp/bid/${biddingId}/items`)
+
+ return {
+ success: true,
+ message: '내정가 산정 기준이 성공적으로 저장되었습니다.',
+ }
+ } catch (error) {
+ console.error('Failed to update target price calculation criteria:', error)
+ return {
+ success: false,
+ error: '내정가 산정 기준 저장에 실패했습니다.',
+ }
+ }
+}
+
// 활성 템플릿 조회 서버 액션
export async function getActiveContractTemplates() {
try {
@@ -1443,4 +3073,829 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId
console.error('Error searching vendors for bidding:', error)
return []
}
+}
+
+// 차수증가 또는 재입찰 함수
+export async function increaseRoundOrRebid(biddingId: number, userId: string, type: 'round_increase' | 'rebidding') {
+ try {
+ const userName = await getUserNameById(userId)
+
+ return await db.transaction(async (tx) => {
+ // 1. 기존 입찰 정보 조회
+ const [existingBidding] = await tx
+ .select()
+ .from(biddings)
+ .where(eq(biddings.id, biddingId))
+ .limit(1)
+
+ if (!existingBidding) {
+ return {
+ success: false,
+ error: '입찰 정보를 찾을 수 없습니다.'
+ }
+ }
+
+ // 2. 입찰번호 파싱 및 차수 증가
+ const currentBiddingNumber = existingBidding.biddingNumber
+
+ // 현재 입찰번호에서 차수 추출 (예: E00025-02 -> 02)
+ const match = currentBiddingNumber.match(/-(\d+)$/)
+ let currentRound = match ? parseInt(match[1]) : 1
+
+ let newBiddingNumber: string
+
+ if (currentRound >= 3) {
+ // -03 이상이면 새로운 번호 생성
+ newBiddingNumber = await generateBiddingNumber(existingBidding.contractType, userId, tx)
+ } else {
+ // -02까지는 차수만 증가
+ const baseNumber = currentBiddingNumber.split('-')[0]
+ newBiddingNumber = `${baseNumber}-${String(currentRound + 1).padStart(2, '0')}`
+ }
+
+ // 3. 새로운 입찰 생성 (기존 정보 복제)
+ const [newBidding] = await tx
+ .insert(biddings)
+ .values({
+ biddingNumber: newBiddingNumber,
+ originalBiddingNumber: null, // 원입찰번호는 단순 정보이므로 null
+ revision: 0,
+ biddingSourceType: existingBidding.biddingSourceType,
+
+ // 기본 정보 복제
+ projectName: existingBidding.projectName,
+ itemName: existingBidding.itemName,
+ title: existingBidding.title,
+ description: existingBidding.description,
+
+ // 계약 정보 복제
+ contractType: existingBidding.contractType,
+ biddingType: existingBidding.biddingType,
+ awardCount: existingBidding.awardCount,
+ contractStartDate: existingBidding.contractStartDate,
+ contractEndDate: existingBidding.contractEndDate,
+
+ // 일정은 초기화 (새로 설정해야 함)
+ preQuoteDate: null,
+ biddingRegistrationDate: new Date(),
+ submissionStartDate: null,
+ submissionEndDate: null,
+ evaluationDate: null,
+
+ // 사양설명회
+ hasSpecificationMeeting: existingBidding.hasSpecificationMeeting,
+
+ // 예산 및 가격 정보 복제
+ currency: existingBidding.currency,
+ budget: existingBidding.budget,
+ targetPrice: existingBidding.targetPrice,
+ targetPriceCalculationCriteria: existingBidding.targetPriceCalculationCriteria,
+ finalBidPrice: null, // 최종입찰가는 초기화
+
+ // PR 정보 복제
+ prNumber: existingBidding.prNumber,
+ hasPrDocument: existingBidding.hasPrDocument,
+
+ // 상태는 내정가 산정으로 초기화
+ status: 'set_target_price',
+ isPublic: existingBidding.isPublic,
+ isUrgent: existingBidding.isUrgent,
+
+ // 구매조직
+ purchasingOrganization: existingBidding.purchasingOrganization,
+
+ // 담당자 정보 복제
+ bidPicId: existingBidding.bidPicId,
+ bidPicName: existingBidding.bidPicName,
+ bidPicCode: existingBidding.bidPicCode,
+ supplyPicId: existingBidding.supplyPicId,
+ supplyPicName: existingBidding.supplyPicName,
+ supplyPicCode: existingBidding.supplyPicCode,
+
+ remarks: `${type === 'round_increase' ? '차수증가' : '재입찰'}`,
+ createdBy: userName,
+ updatedBy: userName,
+ ANFNR: existingBidding.ANFNR,
+ })
+ .returning()
+
+ // 4. 입찰 조건 복제
+ const [existingConditions] = await tx
+ .select()
+ .from(biddingConditions)
+ .where(eq(biddingConditions.biddingId, biddingId))
+ .limit(1)
+
+ if (existingConditions) {
+ await tx
+ .insert(biddingConditions)
+ .values({
+ biddingId: newBidding.id,
+ paymentTerms: existingConditions.paymentTerms,
+ taxConditions: existingConditions.taxConditions,
+ incoterms: existingConditions.incoterms,
+ incotermsOption: existingConditions.incotermsOption,
+ contractDeliveryDate: existingConditions.contractDeliveryDate,
+ shippingPort: existingConditions.shippingPort,
+ destinationPort: existingConditions.destinationPort,
+ isPriceAdjustmentApplicable: existingConditions.isPriceAdjustmentApplicable,
+ sparePartOptions: existingConditions.sparePartOptions,
+ })
+ }
+
+ // 5. PR 아이템 복제
+ const existingPrItems = await tx
+ .select()
+ .from(prItemsForBidding)
+ .where(eq(prItemsForBidding.biddingId, biddingId))
+
+ if (existingPrItems.length > 0) {
+ await tx
+ .insert(prItemsForBidding)
+ .values(
+ existingPrItems.map((item) => ({
+ biddingId: newBidding.id,
+
+ // 기본 정보
+ itemNumber: item.itemNumber,
+ projectId: item.projectId,
+ projectInfo: item.projectInfo,
+ itemInfo: item.itemInfo,
+ shi: item.shi,
+ prNumber: item.prNumber,
+
+ // 자재 그룹 정보
+ materialGroupNumber: item.materialGroupNumber,
+ materialGroupInfo: item.materialGroupInfo,
+
+ // 자재 정보
+ materialNumber: item.materialNumber,
+ materialInfo: item.materialInfo,
+
+ // 납품 일정
+ requestedDeliveryDate: item.requestedDeliveryDate,
+
+ // 가격 정보
+ annualUnitPrice: item.annualUnitPrice,
+ currency: item.currency,
+
+ // 수량 및 중량
+ quantity: item.quantity,
+ quantityUnit: item.quantityUnit,
+ totalWeight: item.totalWeight,
+ weightUnit: item.weightUnit,
+
+ // 단위 정보
+ priceUnit: item.priceUnit,
+ purchaseUnit: item.purchaseUnit,
+ materialWeight: item.materialWeight,
+
+ // WBS 정보
+ wbsCode: item.wbsCode,
+ wbsName: item.wbsName,
+
+ // Cost Center 정보
+ costCenterCode: item.costCenterCode,
+ costCenterName: item.costCenterName,
+
+ // GL Account 정보
+ glAccountCode: item.glAccountCode,
+ glAccountName: item.glAccountName,
+
+ // 내정가 정보
+ targetUnitPrice: item.targetUnitPrice,
+ targetAmount: item.targetAmount,
+ targetCurrency: item.targetCurrency,
+
+ // 예산 정보
+ budgetAmount: item.budgetAmount,
+ budgetCurrency: item.budgetCurrency,
+
+ // 실적 정보
+ actualAmount: item.actualAmount,
+ actualCurrency: item.actualCurrency,
+
+ // SPEC 문서 여부
+ hasSpecDocument: item.hasSpecDocument,
+
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }))
+ )
+ }
+
+ // 6. 벤더 복제 (제출 정보는 초기화)
+ const existingCompanies = await tx
+ .select()
+ .from(biddingCompanies)
+ .where(eq(biddingCompanies.biddingId, biddingId))
+
+ if (existingCompanies.length > 0) {
+ await tx
+ .insert(biddingCompanies)
+ .values(
+ existingCompanies.map((company) => ({
+ biddingId: newBidding.id,
+ companyId: company.companyId,
+ invitedAt: new Date(),
+ invitationStatus: 'pending' as const, // 초대 대기 상태로 초기화
+ // 제출 정보는 초기화
+ submittedAt: null,
+ quotationPrice: null,
+ quotationCurrency: null,
+ quotationValidityDays: null,
+ deliveryDate: null,
+ remarks: null,
+ }))
+ )
+ }
+
+ // 7. 사양설명회 정보 복제 (있는 경우)
+ if (existingBidding.hasSpecificationMeeting) {
+ const [existingMeeting] = await tx
+ .select()
+ .from(specificationMeetings)
+ .where(eq(specificationMeetings.biddingId, biddingId))
+ .limit(1)
+
+ if (existingMeeting) {
+ await tx
+ .insert(specificationMeetings)
+ .values({
+ biddingId: newBidding.id,
+ meetingDate: existingMeeting.meetingDate,
+ meetingTime: existingMeeting.meetingTime,
+ location: existingMeeting.location,
+ address: existingMeeting.address,
+ contactPerson: existingMeeting.contactPerson,
+ contactPhone: existingMeeting.contactPhone,
+ contactEmail: existingMeeting.contactEmail,
+ agenda: existingMeeting.agenda,
+ materials: existingMeeting.materials,
+ notes: existingMeeting.notes,
+ isRequired: existingMeeting.isRequired,
+ })
+ }
+ }
+ // 8. 입찰공고문 정보 복제 (있는 경우)
+ if (existingBidding.hasBiddingNotice) {
+ const [existingNotice] = await tx
+ .select()
+ .from(biddingNoticeTemplate)
+ .where(eq(biddingNoticeTemplate.biddingId, biddingId))
+ .limit(1)
+
+ if (existingNotice) {
+ await tx
+ .insert(biddingNoticeTemplate)
+ .values({
+ biddingId: newBidding.id,
+ title: existingNotice.title,
+ content: existingNotice.content,
+ })
+ }
+ }
+
+ revalidatePath('/bidding')
+ revalidatePath(`/bidding/${newBidding.id}`)
+
+ return {
+ success: true,
+ message: `${type === 'round_increase' ? '차수증가' : '재입찰'}가 완료되었습니다.`,
+ biddingId: newBidding.id,
+ biddingNumber: newBiddingNumber
+ }
+ })
+ } catch (error) {
+ console.error('차수증가/재입찰 실패:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '차수증가/재입찰 중 오류가 발생했습니다.'
+ }
+ }
+}
+
+/**
+ * 벤더의 담당자 목록 조회
+ */
+export async function getVendorContactsByVendorId(vendorId: number) {
+ try {
+ const contacts = await db
+ .select({
+ id: vendorContacts.id,
+ vendorId: vendorContacts.vendorId,
+ contactName: vendorContacts.contactName,
+ contactPosition: vendorContacts.contactPosition,
+ contactDepartment: vendorContacts.contactDepartment,
+ contactTask: vendorContacts.contactTask,
+ contactEmail: vendorContacts.contactEmail,
+ contactPhone: vendorContacts.contactPhone,
+ isPrimary: vendorContacts.isPrimary,
+ createdAt: vendorContacts.createdAt,
+ updatedAt: vendorContacts.updatedAt,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendorId))
+ .orderBy(vendorContacts.isPrimary, vendorContacts.contactName)
+
+ return {
+ success: true,
+ data: contacts
+ }
+ } catch (error) {
+ console.error('Failed to get vendor contacts:', error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '담당자 목록 조회에 실패했습니다.'
+ }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// bid-receive 페이지용 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// bid-receive: 입찰서접수및마감 페이지용 입찰 목록 조회
+export async function getBiddingsForReceive(input: GetBiddingsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 기본 필터 조건들 (입찰서접수및마감에 적합한 상태만)
+ const basicConditions: SQL<unknown>[] = []
+
+ // 입찰서 접수 및 마감과 관련된 상태만 필터링
+ // 'received_quotation', 'bidding_opened', 'bidding_closed' 상태만 조회
+ basicConditions.push(
+ or(
+ eq(biddings.status, 'received_quotation'),
+ eq(biddings.status, 'bidding_opened'),
+ eq(biddings.status, 'bidding_closed')
+ )!
+ )
+
+ if (input.biddingNumber) {
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(biddings.status, status)))!
+ )
+ }
+
+ if (input.contractType && input.contractType.length > 0) {
+ basicConditions.push(
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
+ )
+ }
+
+ if (input.prNumber) {
+ basicConditions.push(ilike(biddings.prNumber, `%${input.prNumber}%`))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
+ }
+
+ // 날짜 필터들
+ if (input.submissionDateFrom) {
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
+ }
+ if (input.submissionDateTo) {
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
+ }
+
+ if (input.createdAtFrom) {
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
+ }
+ if (input.createdAtTo) {
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ const whereConditions: SQL<unknown>[] = []
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(biddings)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 정렬
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
+ })
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(biddings.createdAt))
+ }
+
+ // bid-receive 페이지용 데이터 조회 (필요한 컬럼만 선택)
+ const data = await db
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ title: biddings.title,
+ status: biddings.status,
+ contractType: biddings.contractType,
+ prNumber: biddings.prNumber,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+
+ // 참여 현황 집계
+ participantExpected: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ ), 0)
+ `.as('participant_expected'),
+
+ participantParticipated: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status = 'bidding_submitted'
+ ), 0)
+ `.as('participant_participated'),
+
+ participantDeclined: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('bidding_declined', 'bidding_cancelled')
+ ), 0)
+ `.as('participant_declined'),
+
+ participantPending: sql<number>`
+ COALESCE((
+ SELECT count(*)
+ FROM bidding_companies
+ WHERE bidding_id = ${biddings.id}
+ AND invitation_status IN ('pending', 'bidding_sent', 'bidding_accepted')
+ ), 0)
+ `.as('participant_pending'),
+
+ // 개찰 정보 (bidding_opened 상태에서만 의미 있음)
+ openedAt: biddings.updatedAt, // 개찰일은 업데이트 일시로 대체
+ openedBy: biddings.updatedBy, // 개찰자는 업데이트자로 대체
+ })
+ .from(biddings)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getBiddingsForReceive:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// bid-selection 페이지용 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// bid-selection: 개찰 이후 입찰가 및 정보 확인 페이지용 입찰 목록 조회
+export async function getBiddingsForSelection(input: GetBiddingsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 기본 필터 조건들 (개찰 이후 상태만)
+ const basicConditions: SQL<unknown>[] = []
+
+ // 개찰 이후 상태만 필터링
+ // 'bidding_opened', 'bidding_closed', 'evaluation_of_bidding', 'vendor_selected' 상태만 조회
+ basicConditions.push(
+ or(
+ eq(biddings.status, 'bidding_opened'),
+ eq(biddings.status, 'bidding_closed'),
+ eq(biddings.status, 'evaluation_of_bidding'),
+ eq(biddings.status, 'vendor_selected')
+ )!
+ )
+
+ if (input.biddingNumber) {
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(biddings.status, status)))!
+ )
+ }
+
+ if (input.contractType && input.contractType.length > 0) {
+ basicConditions.push(
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
+ )
+ }
+
+ if (input.prNumber) {
+ basicConditions.push(ilike(biddings.prNumber, `%${input.prNumber}%`))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
+ }
+
+ // 날짜 필터들
+ if (input.submissionDateFrom) {
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
+ }
+ if (input.submissionDateTo) {
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
+ }
+
+ if (input.createdAtFrom) {
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
+ }
+ if (input.createdAtTo) {
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ const whereConditions: SQL<unknown>[] = []
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(biddings)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 정렬
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
+ })
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(biddings.createdAt))
+ }
+
+ // bid-selection 페이지용 데이터 조회
+ const data = await db
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ title: biddings.title,
+ status: biddings.status,
+ contractType: biddings.contractType,
+ prNumber: biddings.prNumber,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+ })
+ .from(biddings)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getBiddingsForSelection:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// bid-failure 페이지용 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// bid-failure: 유찰된 입찰 확인 페이지용 입찰 목록 조회
+export async function getBiddingsForFailure(input: GetBiddingsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 기본 필터 조건들 (유찰된 입찰만)
+ const basicConditions: SQL<unknown>[] = []
+
+ // 유찰된 상태만 필터링
+ basicConditions.push(eq(biddings.status, 'bidding_disposal'))
+
+ if (input.biddingNumber) {
+ basicConditions.push(ilike(biddings.biddingNumber, `%${input.biddingNumber}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(biddings.status, status)))!
+ )
+ }
+
+ if (input.contractType && input.contractType.length > 0) {
+ basicConditions.push(
+ or(...input.contractType.map(type => eq(biddings.contractType, type)))!
+ )
+ }
+
+ if (input.prNumber) {
+ basicConditions.push(ilike(biddings.prNumber, `%${input.prNumber}%`))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(
+ or(
+ ilike(biddings.bidPicName, `%${input.managerName}%`),
+ ilike(biddings.supplyPicName, `%${input.managerName}%`)
+ )!
+ )
+ }
+
+ // 날짜 필터들
+ if (input.createdAtFrom) {
+ basicConditions.push(gte(biddings.createdAt, new Date(input.createdAtFrom)))
+ }
+ if (input.createdAtTo) {
+ basicConditions.push(lte(biddings.createdAt, new Date(input.createdAtTo)))
+ }
+
+ if (input.submissionDateFrom) {
+ basicConditions.push(gte(biddings.submissionStartDate, new Date(input.submissionDateFrom)))
+ }
+ if (input.submissionDateTo) {
+ basicConditions.push(lte(biddings.submissionEndDate, new Date(input.submissionDateTo)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(biddings.biddingNumber, s),
+ ilike(biddings.title, s),
+ ilike(biddings.projectName, s),
+ ilike(biddings.prNumber, s),
+ ilike(biddings.bidPicName, s),
+ ilike(biddings.supplyPicName, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ const whereConditions: SQL<unknown>[] = []
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(biddings)
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 정렬
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof biddings.$inferSelect
+ return sort.desc ? desc(biddings[column]) : asc(biddings[column])
+ })
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(biddings.updatedAt)) // 유찰된 최신순
+ }
+
+ // bid-failure 페이지용 데이터 조회
+ const data = await db
+ .select({
+ // 기본 입찰 정보
+ id: biddings.id,
+ biddingNumber: biddings.biddingNumber,
+ originalBiddingNumber: biddings.originalBiddingNumber,
+ title: biddings.title,
+ status: biddings.status,
+ contractType: biddings.contractType,
+ prNumber: biddings.prNumber,
+
+ // 가격 정보
+ targetPrice: biddings.targetPrice,
+ currency: biddings.currency,
+
+ // 일정 정보
+ biddingRegistrationDate: biddings.biddingRegistrationDate,
+ submissionStartDate: biddings.submissionStartDate,
+ submissionEndDate: biddings.submissionEndDate,
+
+ // 담당자 정보
+ bidPicName: biddings.bidPicName,
+ supplyPicName: biddings.supplyPicName,
+
+ // 유찰 정보 (업데이트 일시를 유찰일로 사용)
+ disposalDate: biddings.updatedAt, // 유찰일
+ disposalUpdatedAt: biddings.updatedAt, // 폐찰수정일
+ disposalUpdatedBy: biddings.updatedBy, // 폐찰수정자
+
+ // 기타 정보
+ createdBy: biddings.createdBy,
+ createdAt: biddings.createdAt,
+ updatedAt: biddings.updatedAt,
+ updatedBy: biddings.updatedBy,
+ })
+ .from(biddings)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getBiddingsForFailure:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
} \ No newline at end of file
diff --git a/lib/bidding/validation.ts b/lib/bidding/validation.ts
index 8476be1c..5cf296e1 100644
--- a/lib/bidding/validation.ts
+++ b/lib/bidding/validation.ts
@@ -1,4 +1,4 @@
-import { BiddingListView, biddings } from "@/db/schema"
+import { BiddingListItem, biddings } from "@/db/schema"
import {
createSearchParamsCache,
parseAsArrayOf,
@@ -14,7 +14,7 @@ export const searchParamsCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<BiddingListView>().withDefault([
+ sort: getSortingStateParser<BiddingListItem>().withDefault([
{ id: "createdAt", desc: true },
]),
@@ -23,6 +23,7 @@ export const searchParamsCache = createSearchParamsCache({
status: parseAsArrayOf(z.enum(biddings.status.enumValues)).withDefault([]),
biddingType: parseAsArrayOf(z.enum(biddings.biddingType.enumValues)).withDefault([]),
contractType: parseAsArrayOf(z.enum(biddings.contractType.enumValues)).withDefault([]),
+ purchasingOrganization: parseAsString.withDefault(""),
managerName: parseAsString.withDefault(""),
// 날짜 필터
@@ -51,19 +52,24 @@ export const createBiddingSchema = z.object({
// ❌ 제거: biddingNumber (자동 생성)
// ❌ 제거: preQuoteDate (나중에 자동 기록)
// ❌ 제거: biddingRegistrationDate (시스템에서 자동 기록)
-
+
revision: z.number().int().min(0).default(0),
-
- // ✅ 프로젝트 정보 (새로 추가)
- projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수
+
+ // ✅ 프로젝트 정보 (새로 추가) - 임시로 optional로 변경
+ projectId: z.number().optional(), // 임시로 필수 해제
projectName: z.string().optional(), // ProjectSelector에서 자동 설정
-
+
// ✅ 필수 필드들
- itemName: z.string().min(1, "품목명은 필수입니다"),
+ itemName: z.string().optional(), // 임시로 필수 해제
title: z.string().min(1, "입찰명은 필수입니다"),
description: z.string().optional(),
content: z.string().optional(),
-
+
+ // 입찰공고 정보
+ noticeType: z.enum(['standard', 'facility', 'unit_price'], {
+ required_error: "입찰공고 타입을 선택해주세요"
+ }),
+
// ✅ 계약 정보 (필수)
contractType: z.enum(biddings.contractType.enumValues, {
required_error: "계약구분을 선택해주세요"
@@ -75,44 +81,90 @@ export const createBiddingSchema = z.object({
awardCount: z.enum(biddings.awardCount.enumValues, {
required_error: "낙찰수를 선택해주세요"
}),
+
+ // ✅ 가격 정보 (조회용으로 readonly 처리)
+ budget: z.string().optional(), // 예산 (조회용)
+ finalBidPrice: z.string().optional(), // 실적가 (조회용)
+ targetPrice: z.string().optional(), // 내정가 (조회용)
+
+ // PR 정보 (조회용)
+ prNumber: z.string().optional(),
+
+ // 계약기간
contractStartDate: z.string().optional(),
contractEndDate: z.string().optional(),
-
+
// ✅ 일정 (제출기간 필수)
- submissionStartDate: z.string().min(1, "제출시작일시는 필수입니다"),
- submissionEndDate: z.string().min(1, "제출마감일시는 필수입니다"),
+ submissionStartDate: z.string().optional(),
+
+ submissionEndDate: z.string().optional(),
+
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"),
-
- // 상태 및 담당자
+
+ // 상태 (조회용)
status: z.enum(biddings.status.enumValues).default("bidding_generated"),
isPublic: z.boolean().default(false),
isUrgent: z.boolean().default(false),
+
+ // 구매조직
+ purchasingOrganization: z.string().optional(),
+
+ // 담당자 정보 (개선된 구조)
+ bidPicId: z.number().int().positive().optional(),
+ bidPicName: z.string().min(1, "입찰담당자는 필수입니다"),
+ bidPicCode: z.string().min(1, "입찰담당자 코드는 필수입니다"),
+ supplyPicId: z.number().int().positive().optional(),
+ supplyPicName: z.string().optional(),
+ supplyPicCode: z.string().optional(),
+
+ // 기존 담당자 정보 (점진적 마이그레이션을 위해 유지)
managerName: z.string().optional(),
managerEmail: z.string().email().optional().or(z.literal("")),
managerPhone: z.string().optional(),
-
+
+ // 구매요청자 (현재 사용자)
+ requesterName: z.string().optional(),
+
// 메타
remarks: z.string().optional(),
- // 입찰 조건 (선택사항이지만, 설정할 경우 필수 항목들이 있음)
+ // 첨부파일 (두 가지 타입으로 구분)
+ attachments: z.array(z.object({
+ id: z.string(),
+ fileName: z.string(),
+ fileSize: z.number().optional(),
+ filePath: z.string(),
+ uploadedAt: z.string().optional(),
+ type: z.enum(['shi', 'vendor']).default('shi'), // SHI용 또는 협력업체용
+ })).default([]),
+ vendorAttachments: z.array(z.object({
+ id: z.string(),
+ fileName: z.string(),
+ fileSize: z.number().optional(),
+ filePath: z.string(),
+ uploadedAt: z.string().optional(),
+ type: z.enum(['shi', 'vendor']).default('vendor'), // SHI용 또는 협력업체용
+ })).default([]),
+
+ // 입찰 조건 (통합된 구조)
biddingConditions: z.object({
- paymentTerms: z.string().min(1, "지급조건은 필수입니다"),
- taxConditions: z.string().min(1, "세금조건은 필수입니다"),
- incoterms: z.string().min(1, "운송조건은 필수입니다"),
- contractDeliveryDate: z.string().min(1, "계약납품일은 필수입니다"),
- shippingPort: z.string().optional(),
- destinationPort: z.string().optional(),
- isPriceAdjustmentApplicable: z.boolean().default(false),
+ paymentTerms: z.string().min(1, "SHI 지급조건은 필수입니다"), // SHI 지급조건
+ taxConditions: z.string().min(1, "SHI 매입부가가치세는 필수입니다"), // SHI 매입부가가치세
+ incoterms: z.string().min(1, "SHI 인도조건은 필수입니다"), // SHI 인도조건
+ incotermsOption: z.string().optional(), // SHI 인도조건2
+ contractDeliveryDate: z.string().optional(),
+ shippingPort: z.string().optional(), // SHI 선적지
+ destinationPort: z.string().optional(), // SHI 하역지
+ isPriceAdjustmentApplicable: z.boolean().default(false), // 하도급법적용여부
sparePartOptions: z.string().optional(),
- }).optional(),
+ }),
}).refine((data) => {
// 제출 기간 검증: 시작일이 마감일보다 이전이어야 함
if (data.submissionStartDate && data.submissionEndDate) {
@@ -138,40 +190,78 @@ export const createBiddingSchema = z.object({
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(),
-
+
+ // 입찰공고 정보
+ noticeType: z.enum(['standard', 'facility', 'unit_price']).optional(),
+
contractType: z.enum(biddings.contractType.enumValues).optional(),
biddingType: z.enum(biddings.biddingType.enumValues).optional(),
+ biddingTypeCustom: z.string().optional(),
awardCount: z.enum(biddings.awardCount.enumValues).optional(),
+
+ // 가격 정보 (조회용)
+ budget: z.string().optional(),
+ finalBidPrice: z.string().optional(),
+ targetPrice: z.string().optional(),
+
+ // PR 정보 (조회용)
+ prNumber: z.string().optional(),
+
+ // 계약기간
contractStartDate: z.string().optional(),
contractEndDate: 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(),
isUrgent: z.boolean().optional(),
+
+ // 구매조직
+ purchasingOrganization: z.string().optional(),
+
+ // 담당자 정보 (개선된 구조)
+ bidPicId: z.number().int().positive().optional(),
+ bidPicName: z.string().min(1, "입찰담당자는 필수입니다").optional(),
+ bidPicCode: z.string().min(1, "입찰담당자 코드는 필수입니다").optional(),
+ supplyPicId: z.number().int().positive().optional(),
+ supplyPicName: z.string().optional(),
+ supplyPicCode: z.string().optional(),
+
+ // 기존 담당자 정보 (점진적 마이그레이션을 위해 유지)
managerName: z.string().optional(),
managerEmail: z.string().email().optional().or(z.literal("")),
managerPhone: z.string().optional(),
-
+
+ // 구매요청자 (현재 사용자)
+ requesterName: z.string().optional(),
+
+ // 입찰 조건
+ biddingConditions: z.object({
+ paymentTerms: z.string().min(1, "SHI 지급조건은 필수입니다").optional(),
+ taxConditions: z.string().min(1, "SHI 매입부가가치세는 필수입니다").optional(),
+ incoterms: z.string().min(1, "SHI 인도조건은 필수입니다").optional(),
+ incotermsOption: z.string().optional(),
+ contractDeliveryDate: z.string().optional(),
+ shippingPort: z.string().optional(),
+ destinationPort: z.string().optional(),
+ isPriceAdjustmentApplicable: z.boolean().default(false),
+ sparePartOptions: z.string().optional(),
+ }),
+
remarks: z.string().optional(),
})
@@ -202,7 +292,7 @@ export const createBiddingSchema = z.object({
taxConditions: z.string().optional(),
deliveryDate: z.string().optional(),
awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다').optional(),
- status: z.enum(['pending', 'submitted', 'selected', 'rejected']).default('pending'),
+ invitationStatus: z.enum(['pending', 'pre_quote_sent', 'pre_quote_accepted', 'pre_quote_declined', 'pre_quote_submitted', 'bidding_sent', 'bidding_accepted', 'bidding_declined', 'bidding_cancelled', 'bidding_submitted']).default('pending'),
})
// 협력업체 정보 업데이트 스키마
@@ -219,7 +309,7 @@ export const createBiddingSchema = z.object({
taxConditions: z.string().optional(),
deliveryDate: z.string().optional(),
awardRatio: z.number().min(0).max(100, '발주비율은 0-100 사이여야 합니다').optional(),
- status: z.enum(['pending', 'submitted', 'selected', 'rejected']).optional(),
+ invitationStatus: z.enum(['pending', 'pre_quote_sent', 'pre_quote_accepted', 'pre_quote_declined', 'pre_quote_submitted', 'bidding_sent', 'bidding_accepted', 'bidding_declined', 'bidding_cancelled', 'bidding_submitted']).optional(),
})
// 낙찰 선택 스키마
diff --git a/lib/bidding/vendor/components/pr-items-pricing-table.tsx b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
index 483bce5c..efa10af2 100644
--- a/lib/bidding/vendor/components/pr-items-pricing-table.tsx
+++ b/lib/bidding/vendor/components/pr-items-pricing-table.tsx
@@ -32,16 +32,27 @@ import {
interface PrItem {
id: number
+ biddingId: number
itemNumber: string | null
- prNumber: string | null
+ projectId: number | null
+ projectInfo: string | null
itemInfo: string | null
- materialDescription: string | null
+ shi: string | null
+ materialGroupNumber: string | null
+ materialGroupInfo: string | null
+ materialNumber: string | null
+ materialInfo: string | null
+ requestedDeliveryDate: Date | null
+ annualUnitPrice: string | null
+ currency: string | null
quantity: string | null
quantityUnit: string | null
totalWeight: string | null
weightUnit: string | null
- currency: string | null
- requestedDeliveryDate: string | null
+ priceUnit: string | null
+ purchaseUnit: string | null
+ materialWeight: string | null
+ prNumber: string | null
hasSpecDocument: boolean | null
}
@@ -283,8 +294,10 @@ export function PrItemsPricingTable({
<TableHead>자재내역</TableHead>
<TableHead>수량</TableHead>
<TableHead>단위</TableHead>
+ <TableHead>구매단위</TableHead>
<TableHead>중량</TableHead>
<TableHead>중량단위</TableHead>
+ <TableHead>가격단위</TableHead>
<TableHead>SHI 납품요청일</TableHead>
<TableHead>견적단가</TableHead>
<TableHead>견적금액</TableHead>
@@ -315,18 +328,20 @@ export function PrItemsPricingTable({
</div>
</TableCell>
<TableCell>
- <div className="max-w-32 truncate" title={item.materialDescription || ''}>
- {item.materialDescription || '-'}
+ <div className="max-w-32 truncate" title={item.materialInfo || ''}>
+ {item.materialInfo || '-'}
</div>
</TableCell>
<TableCell className="text-right">
{item.quantity ? parseFloat(item.quantity).toLocaleString() : '-'}
</TableCell>
<TableCell>{item.quantityUnit || '-'}</TableCell>
+ <TableCell>{item.purchaseUnit || '-'}</TableCell>
<TableCell className="text-right">
{item.totalWeight ? parseFloat(item.totalWeight).toLocaleString() : '-'}
</TableCell>
<TableCell>{item.weightUnit || '-'}</TableCell>
+ <TableCell>{item.priceUnit || '-'}</TableCell>
<TableCell>
{item.requestedDeliveryDate ?
formatDate(item.requestedDeliveryDate, 'KR') : '-'
diff --git a/lib/bidding/vendor/partners-bidding-detail.tsx b/lib/bidding/vendor/partners-bidding-detail.tsx
index f9241f7b..273c0667 100644
--- a/lib/bidding/vendor/partners-bidding-detail.tsx
+++ b/lib/bidding/vendor/partners-bidding-detail.tsx
@@ -1,11 +1,16 @@
'use client'
import * as React from 'react'
+
+// 브라우저 환경 체크
+const isBrowser = typeof window !== 'undefined'
import { useRouter } from 'next/navigation'
+import { useTransition } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
ArrowLeft,
User,
@@ -15,8 +20,10 @@ import {
XCircle,
Save,
FileText,
- Building2,
- Package
+ Package,
+ Trash2,
+ Calendar,
+ ChevronDown
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
@@ -24,19 +31,26 @@ import {
getBiddingDetailsForPartners,
submitPartnerResponse,
updatePartnerBiddingParticipation,
- saveBiddingDraft
+ saveBiddingDraft,
+ getPriceAdjustmentFormByBiddingCompanyId
} from '../detail/service'
+import { cancelBiddingResponse } from '../detail/bidding-actions'
import { getPrItemsForBidding, getSavedPrItemQuotations } from '@/lib/bidding/pre-quote/service'
+import { getBiddingConditions } from '@/lib/bidding/service'
import { PrItemsPricingTable } from './components/pr-items-pricing-table'
import { SimpleFileUpload } from './components/simple-file-upload'
+import { getTaxConditionName } from "@/lib/tax-conditions/types"
import {
biddingStatusLabels,
contractTypeLabels,
biddingTypeLabels
} from '@/db/schema'
import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
import { useSession } from 'next-auth/react'
+import { getBiddingNotice } from '../service'
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
interface PartnersBiddingDetailProps {
biddingId: number
@@ -51,7 +65,6 @@ interface BiddingDetail {
itemName: string | null
title: string
description: string | null
- content: string | null
contractType: string
biddingType: string
awardCount: string | null
@@ -66,33 +79,46 @@ interface BiddingDetail {
budget: number | null
targetPrice: number | null
status: string
- managerName: string | null
- managerEmail: string | null
- managerPhone: string | null
+ bidPicName: string | null // 입찰담당자
+ supplyPicName: string | null // 조달담당자
biddingCompanyId: number
biddingId: number
invitationStatus: string
finalQuoteAmount: number | null
finalQuoteSubmittedAt: Date | null
+ isFinalSubmission: boolean
isWinner: boolean
isAttendingMeeting: boolean | null
isBiddingParticipated: boolean | null
additionalProposals: string | null
responseSubmittedAt: Date | null
+ priceAdjustmentResponse: boolean | null // 연동제 적용 여부
+ isPreQuoteParticipated: boolean | null // 사전견적 참여 여부
}
interface PrItem {
id: number
+ biddingId: number
itemNumber: string | null
- prNumber: string | null
+ projectId: number | null
+ projectInfo: string | null
itemInfo: string | null
- materialDescription: string | null
+ shi: string | null
+ materialGroupNumber: string | null
+ materialGroupInfo: string | null
+ materialNumber: string | null
+ materialInfo: string | null
+ requestedDeliveryDate: Date | null
+ annualUnitPrice: string | null
+ currency: string | null
quantity: string | null
quantityUnit: string | null
totalWeight: string | null
weightUnit: string | null
- currency: string | null
- requestedDeliveryDate: string | null
+ priceUnit: string | null
+ purchaseUnit: string | null
+ materialWeight: string | null
+ prNumber: string | null
hasSpecDocument: boolean | null
}
@@ -104,6 +130,22 @@ interface BiddingPrItemQuotation {
technicalSpecification?: string
}
+interface BiddingConditions {
+ id?: number
+ biddingId?: number
+ paymentTerms?: string
+ taxConditions?: string
+ incoterms?: string
+ incotermsOption?: string
+ contractDeliveryDate?: string
+ shippingPort?: string
+ destinationPort?: string
+ isPriceAdjustmentApplicable?: boolean
+ sparePartOptions?: string
+ createdAt?: string
+ updatedAt?: string
+}
+
export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingDetailProps) {
const router = useRouter()
const { toast } = useToast()
@@ -114,7 +156,23 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
const [isUpdatingParticipation, setIsUpdatingParticipation] = React.useState(false)
const [isSavingDraft, setIsSavingDraft] = React.useState(false)
const [isSubmitting, setIsSubmitting] = React.useState(false)
-
+ const [isCancelling, setIsCancelling] = React.useState(false)
+ const [isFinalSubmission, setIsFinalSubmission] = React.useState(false)
+ const [isBiddingNoticeLoading, setIsBiddingNoticeLoading] = React.useState(false)
+
+ // 입찰공고 관련 상태
+ const [biddingNotice, setBiddingNotice] = React.useState<{
+ id?: number
+ biddingId?: number
+ title?: string
+ content?: string
+ isTemplate?: boolean
+ createdAt?: string
+ updatedAt?: string
+ } | null>(null)
+ const [biddingConditions, setBiddingConditions] = React.useState<BiddingConditions | null>(null)
+ const [isNoticeOpen, setIsNoticeOpen] = React.useState(false)
+
// 품목별 견적 관련 상태
const [prItems, setPrItems] = React.useState<PrItem[]>([])
const [prItemQuotations, setPrItemQuotations] = React.useState<BiddingPrItemQuotation[]>([])
@@ -125,21 +183,95 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
finalQuoteAmount: '',
proposedContractDeliveryDate: '',
additionalProposals: '',
+ priceAdjustmentResponse: null as boolean | null, // null: 미선택, true: 적용, false: 미적용
+ })
+
+ // 연동제 폼 상태
+ const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({
+ itemName: '',
+ adjustmentReflectionPoint: '',
+ majorApplicableRawMaterial: '',
+ adjustmentFormula: '',
+ rawMaterialPriceIndex: '',
+ referenceDate: '',
+ comparisonDate: '',
+ adjustmentRatio: '',
+ notes: '',
+ adjustmentConditions: '',
+ majorNonApplicableRawMaterial: '',
+ adjustmentPeriod: '',
+ contractorWriter: '',
+ adjustmentDate: '',
+ nonApplicableReason: '', // 연동제 미희망 사유
})
const userId = session.data?.user?.id || ''
// 데이터 로드
+ // 입찰공고 로드 함수
+ const loadBiddingNotice = React.useCallback(async () => {
+ setIsBiddingNoticeLoading(true)
+ try {
+ const notice = await getBiddingNotice(biddingId)
+ console.log('입찰공고 로드 성공:', notice)
+ setBiddingNotice(notice)
+ } catch (error) {
+ console.error('Failed to load bidding notice:', error)
+ } finally {
+ setIsBiddingNoticeLoading(false)
+ }
+ }, [biddingId])
+
React.useEffect(() => {
const loadData = async () => {
try {
setIsLoading(true)
- const [result, prItemsResult] = await Promise.all([
- getBiddingDetailsForPartners(biddingId, companyId),
- getPrItemsForBidding(biddingId)
+ // 데이터 로드 시작 로그
+ console.log('입찰 상세 데이터 로드 시작:', { biddingId, companyId })
+
+ console.log('데이터베이스 쿼리 시작...')
+
+ const [result, prItemsResult, noticeResult, conditionsResult] = await Promise.all([
+ getBiddingDetailsForPartners(biddingId, companyId).catch(error => {
+ console.error('Failed to get bidding details:', error)
+ return null
+ }),
+ getPrItemsForBidding(biddingId).catch(error => {
+ console.error('Failed to get PR items:', error)
+ return []
+ }),
+ loadBiddingNotice().catch(error => {
+ console.error('Failed to load bidding notice:', error)
+ return null
+ }),
+ getBiddingConditions(biddingId).catch(error => {
+ console.error('Failed to load bidding conditions:', error)
+ return null
+ })
])
-
+
+ console.log('데이터베이스 쿼리 완료:', {
+ resultExists: !!result,
+ prItemsExists: !!prItemsResult,
+ noticeExists: !!noticeResult,
+ conditionsExists: !!conditionsResult
+ })
+
+ console.log('데이터 로드 완료:', {
+ result: !!result,
+ prItemsCount: Array.isArray(prItemsResult) ? prItemsResult.length : 0,
+ noticeResult: !!noticeResult,
+ conditionsResult: !!conditionsResult
+ })
+
if (result) {
+ console.log('입찰 상세 데이터 로드 성공:', {
+ biddingId: result.biddingId,
+ isBiddingParticipated: result.isBiddingParticipated,
+ invitationStatus: result.invitationStatus,
+ finalQuoteAmount: result.finalQuoteAmount
+ })
+
setBiddingDetail(result)
// 기존 응답 데이터로 폼 초기화
@@ -147,7 +279,14 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
finalQuoteAmount: result.finalQuoteAmount?.toString() || '',
proposedContractDeliveryDate: result.proposedContractDeliveryDate || '',
additionalProposals: result.additionalProposals || '',
+ priceAdjustmentResponse: result.priceAdjustmentResponse || null,
})
+
+ // 입찰 조건 로드
+ if (conditionsResult) {
+ console.log('입찰 조건 로드:', conditionsResult)
+ setBiddingConditions(conditionsResult)
+ }
}
// PR 아이템 설정
@@ -158,29 +297,70 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
try {
// 사전견적 데이터를 가져와서 본입찰용으로 변환
const preQuoteData = await getSavedPrItemQuotations(result.biddingCompanyId)
-
- // 사전견적 데이터를 본입찰 포맷으로 변환
- const convertedQuotations = preQuoteData.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice,
- bidAmount: item.bidAmount,
- proposedDeliveryDate: item.proposedDeliveryDate || undefined,
- technicalSpecification: item.technicalSpecification || undefined
- }))
-
- setPrItemQuotations(convertedQuotations)
-
- // 총 금액 계산
- const total = convertedQuotations.reduce((sum, q) => sum + q.bidAmount, 0)
- setTotalQuotationAmount(total)
+
+ if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) {
+ console.log('사전견적 데이터:', preQuoteData)
+
+ // 사전견적 데이터를 본입찰 포맷으로 변환
+ const convertedQuotations = preQuoteData
+ .filter(item => item && typeof item === 'object' && item.prItemId)
+ .map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice,
+ bidAmount: item.bidAmount,
+ proposedDeliveryDate: item.proposedDeliveryDate || undefined,
+ technicalSpecification: item.technicalSpecification || undefined
+ }))
+
+ console.log('변환된 견적 데이터:', convertedQuotations)
+
+ if (Array.isArray(convertedQuotations) && convertedQuotations.length > 0) {
+ setPrItemQuotations(convertedQuotations)
+
+ // 총 금액 계산
+ const total = convertedQuotations.reduce((sum, q) => {
+ const amount = Number(q.bidAmount) || 0
+ return sum + amount
+ }, 0)
+ setTotalQuotationAmount(total)
+ console.log('계산된 총 금액:', total)
+ }
+ }
// 응찰 확정 시에만 사전견적 금액을 finalQuoteAmount로 설정
- if (totalQuotationAmount > 0 && result.isBiddingParticipated === true) {
+ if (totalQuotationAmount > 0 && result?.isBiddingParticipated === true) {
+ console.log('응찰 확정됨, 사전견적 금액 설정:', totalQuotationAmount)
+ console.log('사전견적 금액을 finalQuoteAmount로 설정:', totalQuotationAmount)
setResponseData(prev => ({
...prev,
finalQuoteAmount: totalQuotationAmount.toString()
}))
}
+
+ // 연동제 데이터 로드 (사전견적에서 답변했으면 로드, 아니면 입찰 조건 확인)
+ if (result.priceAdjustmentResponse !== null) {
+ // 사전견적에서 이미 답변한 경우 - 연동제 폼 로드
+ const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(result.biddingCompanyId)
+ if (savedPriceAdjustmentForm) {
+ setPriceAdjustmentForm({
+ itemName: savedPriceAdjustmentForm.itemName || '',
+ adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '',
+ majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '',
+ adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '',
+ rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '',
+ referenceDate: savedPriceAdjustmentForm.referenceDate || '',
+ comparisonDate: savedPriceAdjustmentForm.comparisonDate || '',
+ adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio || '',
+ notes: savedPriceAdjustmentForm.notes || '',
+ adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '',
+ majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '',
+ adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '',
+ contractorWriter: savedPriceAdjustmentForm.contractorWriter || '',
+ adjustmentDate: savedPriceAdjustmentForm.adjustmentDate || '',
+ nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '',
+ })
+ }
+ }
} catch (error) {
console.error('Failed to load pre-quote data:', error)
}
@@ -229,23 +409,38 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (participated && updatedDetail.biddingCompanyId) {
try {
const preQuoteData = await getSavedPrItemQuotations(updatedDetail.biddingCompanyId)
- const convertedQuotations = preQuoteData.map(item => ({
- prItemId: item.prItemId,
- bidUnitPrice: item.bidUnitPrice,
- bidAmount: item.bidAmount,
- proposedDeliveryDate: item.proposedDeliveryDate || undefined,
- technicalSpecification: item.technicalSpecification || undefined
- }))
-
- setPrItemQuotations(convertedQuotations)
- const total = convertedQuotations.reduce((sum, q) => sum + q.bidAmount, 0)
- setTotalQuotationAmount(total)
-
- if (total > 0) {
- setResponseData(prev => ({
- ...prev,
- finalQuoteAmount: total.toString()
- }))
+
+ if (preQuoteData && Array.isArray(preQuoteData) && preQuoteData.length > 0) {
+ console.log('참여확정 후 사전견적 데이터:', preQuoteData)
+
+ const convertedQuotations = preQuoteData
+ .filter(item => item && typeof item === 'object' && item.prItemId)
+ .map(item => ({
+ prItemId: item.prItemId,
+ bidUnitPrice: item.bidUnitPrice,
+ bidAmount: item.bidAmount,
+ proposedDeliveryDate: item.proposedDeliveryDate || undefined,
+ technicalSpecification: item.technicalSpecification || undefined
+ }))
+
+ console.log('참여확정 후 변환된 견적 데이터:', convertedQuotations)
+
+ if (Array.isArray(convertedQuotations) && convertedQuotations.length > 0) {
+ setPrItemQuotations(convertedQuotations)
+ const total = convertedQuotations.reduce((sum, q) => {
+ const amount = Number(q.bidAmount) || 0
+ return sum + amount
+ }, 0)
+ setTotalQuotationAmount(total)
+ console.log('참여확정 후 계산된 총 금액:', total)
+
+ if (total > 0) {
+ setResponseData(prev => ({
+ ...prev,
+ finalQuoteAmount: total.toString()
+ }))
+ }
+ }
}
} catch (error) {
console.error('Failed to load pre-quote data after participation:', error)
@@ -356,6 +551,59 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
}
}
+ // 응찰 취소 핸들러
+ const handleCancelResponse = async () => {
+ if (!biddingDetail || !userId) return
+
+ // 최종제출한 경우 취소 불가
+ if (biddingDetail.isFinalSubmission) {
+ toast({
+ title: '취소 불가',
+ description: '최종 제출된 응찰은 취소할 수 없습니다.',
+ variant: 'destructive',
+ })
+ return
+ }
+
+ if (isBrowser && !window.confirm('응찰을 취소하시겠습니까? 작성한 견적 내용이 모두 삭제됩니다.')) {
+ return
+ }
+
+ setIsCancelling(true)
+ try {
+ const result = await cancelBiddingResponse(biddingDetail.biddingCompanyId, userId)
+
+ if (result.success) {
+ toast({
+ title: '응찰 취소 완료',
+ description: '응찰이 취소되었습니다.',
+ })
+ // 페이지 새로고침
+ if (isBrowser) {
+ window.location.reload()
+ } else {
+ // 서버사이드에서는 라우터로 이동
+ router.push(`/partners/bid/${biddingId}`)
+ }
+ } else {
+ toast({
+ title: '응찰 취소 실패',
+ description: result.error,
+ variant: 'destructive',
+ })
+ }
+ } catch (error) {
+ console.error('Failed to cancel bidding response:', error)
+ toast({
+ title: '오류',
+ description: '응찰 취소에 실패했습니다.',
+ variant: 'destructive',
+ })
+ } finally {
+ setIsCancelling(false)
+ }
+ }
+
const handleSubmitResponse = () => {
if (!biddingDetail) return
// 입찰 마감 상태 체크
@@ -412,6 +660,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
finalQuoteAmount: parseFloat(responseData.finalQuoteAmount),
proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
additionalProposals: responseData.additionalProposals,
+ isFinalSubmission, // 최종제출 여부 추가
prItemQuotations: prItemQuotations.length > 0 ? prItemQuotations.map(q => ({
prItemId: q.prItemId,
bidUnitPrice: q.bidUnitPrice,
@@ -425,8 +674,8 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
if (result.success) {
toast({
- title: '응찰 완료',
- description: '견적이 성공적으로 제출되었습니다.',
+ title: isFinalSubmission ? '응찰 완료' : '임시 저장 완료',
+ description: isFinalSubmission ? '견적이 최종 제출되었습니다.' : '견적이 임시 저장되었습니다.',
})
// 데이터 새로고침
@@ -488,9 +737,6 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<Badge variant="outline" className="font-mono text-xs">
{biddingDetail.biddingNumber}
</Badge>
- <Badge variant="outline" className="font-mono">
- Rev. {biddingDetail.revision ?? 0}
- </Badge>
<Badge variant={
biddingDetail.status === 'bidding_disposal' ? 'destructive' :
biddingDetail.status === 'vendor_selected' ? 'default' :
@@ -525,20 +771,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div>
- <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label>
- <div className="flex items-center gap-2 mt-1">
- <Building2 className="w-4 h-4" />
- <span>{biddingDetail.projectName || '미설정'}</span>
- </div>
- </div>
- <div>
- <Label className="text-sm font-medium text-muted-foreground">품목</Label>
- <div className="flex items-center gap-2 mt-1">
- <Package className="w-4 h-4" />
- <span>{biddingDetail.itemName || '미설정'}</span>
- </div>
- </div>
+
<div>
<Label className="text-sm font-medium text-muted-foreground">계약구분</Label>
<div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div>
@@ -552,22 +785,87 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : biddingDetail.awardCount === 'multiple' ? '복수' : '미설정'}</div>
</div>
<div>
- <Label className="text-sm font-medium text-muted-foreground">입찰 담당자</Label>
+ <Label className="text-sm font-medium text-muted-foreground">입찰담당자</Label>
<div className="flex items-center gap-2 mt-1">
<User className="w-4 h-4" />
- <span>{biddingDetail.managerName || '미설정'}</span>
+ <span>{biddingDetail.bidPicName || '미설정'}</span>
</div>
</div>
- </div>
-
- {/* {biddingDetail.budget && (
<div>
- <Label className="text-sm font-medium text-muted-foreground">예산</Label>
+ <Label className="text-sm font-medium text-muted-foreground">조달담당자</Label>
<div className="flex items-center gap-2 mt-1">
- <DollarSign className="w-4 h-4" />
- <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span>
+ <User className="w-4 h-4" />
+ <span>{biddingDetail.supplyPicName || '미설정'}</span>
+ </div>
+ </div>
+ </div>
+
+ {/* 계약기간 */}
+ {biddingDetail.contractStartDate && biddingDetail.contractEndDate && (
+ <div className="pt-4 border-t">
+ <Label className="text-sm font-medium text-muted-foreground mb-2 block">계약기간</Label>
+ <div className="p-3 bg-muted/50 rounded-lg">
+ <div className="flex items-center gap-2 text-sm">
+ <span className="font-medium">{formatDate(biddingDetail.contractStartDate, 'KR')}</span>
+ <span className="text-muted-foreground">~</span>
+ <span className="font-medium">{formatDate(biddingDetail.contractEndDate, 'KR')}</span>
+ </div>
</div>
</div>
+ )}
+
+
+ {/* 제출 마감일 D-day */}
+ {/* {biddingDetail.submissionEndDate && (
+ <div className="pt-4 border-t">
+ <Label className="text-sm font-medium text-muted-foreground mb-2 block">제출 마감 정보</Label>
+ {(() => {
+ const now = new Date()
+ const deadline = new Date(biddingDetail.submissionEndDate)
+ const isExpired = deadline < now
+ const timeLeft = deadline.getTime() - now.getTime()
+ const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24))
+ const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
+
+ return (
+ <div className={`p-3 rounded-lg border-2 ${
+ isExpired
+ ? 'border-red-200 bg-red-50'
+ : daysLeft <= 1
+ ? 'border-orange-200 bg-orange-50'
+ : 'border-green-200 bg-green-50'
+ }`}>
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Calendar className="w-5 h-5" />
+ <span className="font-medium">제출 마감일:</span>
+ <span className="text-lg font-semibold">
+ {formatDate(biddingDetail.submissionEndDate, 'KR')}
+ </span>
+ </div>
+ {isExpired ? (
+ <Badge variant="destructive" className="ml-2">
+ 마감됨
+ </Badge>
+ ) : daysLeft <= 1 ? (
+ <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800">
+ {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`}
+ </Badge>
+ ) : (
+ <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800">
+ {daysLeft}일 남음
+ </Badge>
+ )}
+ </div>
+ {isExpired && (
+ <div className="mt-2 text-sm text-red-600">
+ ⚠️ 제출 마감일이 지났습니다. 입찰 제출이 불가능합니다.
+ </div>
+ )}
+ </div>
+ )
+ })()}
+ </div>
)} */}
{/* 일정 정보 */}
@@ -576,7 +874,7 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
{biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && (
<div>
- <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')}
+ <span className="font-medium">응찰기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')}
</div>
)}
{biddingDetail.evaluationDate && (
@@ -589,6 +887,130 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
</CardContent>
</Card>
+ {/* 입찰공고 토글 섹션 */}
+ {biddingNotice && (
+ <Card>
+ <Collapsible open={isNoticeOpen} onOpenChange={setIsNoticeOpen}>
+ <CollapsibleTrigger asChild>
+ <CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
+ <CardTitle className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ 입찰공고 내용
+ </div>
+ <ChevronDown className={`w-5 h-5 transition-transform ${isNoticeOpen ? 'rotate-180' : ''}`} />
+ </CardTitle>
+ </CardHeader>
+ </CollapsibleTrigger>
+ <CollapsibleContent>
+ <CardContent className="pt-0">
+ <div className="p-4 bg-muted/50 rounded-lg">
+ {biddingNotice.title && (
+ <h3 className="font-semibold text-lg mb-3">{biddingNotice.title}</h3>
+ )}
+ {biddingNotice.content ? (
+ <div
+ className="prose prose-sm max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: biddingNotice.content
+ }}
+ />
+ ) : (
+ <p className="text-muted-foreground">입찰공고 내용이 없습니다.</p>
+ )}
+ </div>
+ </CardContent>
+ </CollapsibleContent>
+ </Collapsible>
+ </Card>
+ )}
+
+ {/* 현재 설정된 조건 섹션 */}
+ {biddingConditions && (
+ <Card>
+ <CardHeader>
+ <CardTitle>현재 설정된 입찰 조건</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
+ <div>
+ <Label className="text-muted-foreground">지급조건</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.paymentTerms || "미설정"}</p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">부가세구분</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">
+ {biddingConditions.taxConditions
+ ? getTaxConditionName(biddingConditions.taxConditions)
+ : "미설정"
+ }
+ </p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">인도조건</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.incoterms || "미설정"}</p>
+ </div>
+ </div>
+ <div>
+ <Label className="text-muted-foreground">인도조건2</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.incotermsOption || "미설정"}</p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">계약 납기일</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">
+ {biddingConditions.contractDeliveryDate
+ ? formatDate(biddingConditions.contractDeliveryDate, 'KR')
+ : "미설정"
+ }
+ </p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">선적지</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.shippingPort || "미설정"}</p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">하역지</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.destinationPort || "미설정"}</p>
+ </div>
+ </div>
+
+ <div>
+ <Label className="text-muted-foreground">연동제 적용</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
+ </div>
+ </div>
+
+
+ <div >
+ <Label className="text-muted-foreground">스페어파트 옵션</Label>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="font-medium">{biddingConditions.sparePartOptions}</p>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+
{/* 참여 상태에 따른 섹션 표시 */}
{biddingDetail.isBiddingParticipated === false ? (
@@ -687,25 +1109,315 @@ export function PartnersBiddingDetail({ biddingId, companyId }: PartnersBiddingD
readOnly={false}
/>
)}
+
+ {/* 연동제 적용 여부 - SHI가 연동제를 요구하고, 사전견적에서 답변하지 않은 경우만 표시 */}
+ {biddingConditions?.isPriceAdjustmentApplicable && biddingDetail.priceAdjustmentResponse === null && (
+ <>
+ <div className="space-y-3 p-4 border rounded-lg bg-muted/30">
+ <Label className="font-semibold text-base">연동제 적용 여부 *</Label>
+ <RadioGroup
+ value={responseData.priceAdjustmentResponse === null ? 'none' : responseData.priceAdjustmentResponse ? 'apply' : 'not-apply'}
+ onValueChange={(value) => {
+ const newValue = value === 'apply' ? true : value === 'not-apply' ? false : null
+ setResponseData({...responseData, priceAdjustmentResponse: newValue})
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="apply" id="price-adjustment-apply" />
+ <Label htmlFor="price-adjustment-apply" className="font-normal cursor-pointer">
+ 연동제 적용
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="not-apply" id="price-adjustment-not-apply" />
+ <Label htmlFor="price-adjustment-not-apply" className="font-normal cursor-pointer">
+ 연동제 미적용
+ </Label>
+ </div>
+ </RadioGroup>
+ </div>
+
+ {/* 연동제 상세 정보 */}
+ {responseData.priceAdjustmentResponse !== null && (
+ <Card className="mt-6">
+ <CardHeader>
+ <CardTitle className="text-lg">하도급대금등 연동표</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 공통 필드 - 품목등의 명칭 */}
+ <div className="space-y-2">
+ <Label htmlFor="itemName">품목등의 명칭 *</Label>
+ <Input
+ id="itemName"
+ value={priceAdjustmentForm.itemName}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, itemName: e.target.value})}
+ placeholder="품목명을 입력하세요"
+ required
+ />
+ </div>
+
+ {/* 연동제 적용 시 - 모든 필드 표시 */}
+ {responseData.priceAdjustmentResponse === true && (
+ <>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentReflectionPoint">조정대금 반영시점 *</Label>
+ <Input
+ id="adjustmentReflectionPoint"
+ value={priceAdjustmentForm.adjustmentReflectionPoint}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentReflectionPoint: e.target.value})}
+ placeholder="반영시점을 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentRatio">연동 비율 (%) *</Label>
+ <Input
+ id="adjustmentRatio"
+ type="number"
+ step="0.01"
+ value={priceAdjustmentForm.adjustmentRatio}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentRatio: e.target.value})}
+ placeholder="비율을 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentPeriod">조정주기 *</Label>
+ <Input
+ id="adjustmentPeriod"
+ value={priceAdjustmentForm.adjustmentPeriod}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentPeriod: e.target.value})}
+ placeholder="조정주기를 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="referenceDate">기준시점 *</Label>
+ <Input
+ id="referenceDate"
+ type="date"
+ value={priceAdjustmentForm.referenceDate}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, referenceDate: e.target.value})}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="comparisonDate">비교시점 *</Label>
+ <Input
+ id="comparisonDate"
+ type="date"
+ value={priceAdjustmentForm.comparisonDate}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, comparisonDate: e.target.value})}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자 *</Label>
+ <Input
+ id="contractorWriter"
+ value={priceAdjustmentForm.contractorWriter}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})}
+ placeholder="작성자명을 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentDate">조정일 *</Label>
+ <Input
+ id="adjustmentDate"
+ type="date"
+ value={priceAdjustmentForm.adjustmentDate}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentDate: e.target.value})}
+ required
+ />
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="majorApplicableRawMaterial">연동대상 주요 원재료 *</Label>
+ <Textarea
+ id="majorApplicableRawMaterial"
+ value={priceAdjustmentForm.majorApplicableRawMaterial}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorApplicableRawMaterial: e.target.value})}
+ placeholder="연동 대상 원재료를 입력하세요"
+ rows={3}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentFormula">하도급대금등 연동 산식 *</Label>
+ <Textarea
+ id="adjustmentFormula"
+ value={priceAdjustmentForm.adjustmentFormula}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentFormula: e.target.value})}
+ placeholder="연동 산식을 입력하세요"
+ rows={3}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="rawMaterialPriceIndex">원재료 가격 기준지표 *</Label>
+ <Textarea
+ id="rawMaterialPriceIndex"
+ value={priceAdjustmentForm.rawMaterialPriceIndex}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, rawMaterialPriceIndex: e.target.value})}
+ placeholder="가격 기준지표를 입력하세요"
+ rows={2}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="adjustmentConditions">조정요건 *</Label>
+ <Textarea
+ id="adjustmentConditions"
+ value={priceAdjustmentForm.adjustmentConditions}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentConditions: e.target.value})}
+ placeholder="조정요건을 입력하세요"
+ rows={2}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="priceAdjustmentNotes">기타 사항</Label>
+ <Textarea
+ id="priceAdjustmentNotes"
+ value={priceAdjustmentForm.notes}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, notes: e.target.value})}
+ placeholder="기타 사항을 입력하세요"
+ rows={2}
+ />
+ </div>
+ </>
+ )}
+
+ {/* 연동제 미적용 시 - 제한된 필드만 표시 */}
+ {responseData.priceAdjustmentResponse === false && (
+ <>
+ <div className="space-y-2">
+ <Label htmlFor="majorNonApplicableRawMaterial">연동제 미적용 주요 원재료 *</Label>
+ <Textarea
+ id="majorNonApplicableRawMaterial"
+ value={priceAdjustmentForm.majorNonApplicableRawMaterial}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorNonApplicableRawMaterial: e.target.value})}
+ placeholder="연동 미적용 원재료를 입력하세요"
+ rows={2}
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자 *</Label>
+ <Input
+ id="contractorWriter"
+ value={priceAdjustmentForm.contractorWriter}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})}
+ placeholder="작성자명을 입력하세요"
+ required
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="nonApplicableReason">연동제 미희망 사유 *</Label>
+ <Textarea
+ id="nonApplicableReason"
+ value={priceAdjustmentForm.nonApplicableReason}
+ onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, nonApplicableReason: e.target.value})}
+ placeholder="미희망 사유를 입력하세요"
+ rows={3}
+ required
+ />
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ )}
+ </>
+ )}
+
+ {/* 사전견적에서 이미 답변한 경우 - 읽기 전용으로 표시 */}
+ {biddingDetail.priceAdjustmentResponse !== null && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">연동제 적용 정보 (사전견적 제출 완료)</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="p-4 bg-muted/30 rounded-lg">
+ <div className="flex items-center gap-2 mb-2">
+ <CheckCircle className="w-5 h-5 text-green-600" />
+ <span className="font-semibold">
+ {biddingDetail.priceAdjustmentResponse ? '연동제 적용' : '연동제 미적용'}
+ </span>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ 사전견적에서 이미 연동제 관련 정보를 제출하였습니다. 본입찰에서는 별도의 연동제 정보 입력이 필요하지 않습니다.
+ </p>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 최종제출 체크박스 */}
+ {!biddingDetail.isFinalSubmission && (
+ <div className="flex items-center space-x-2 p-4 border rounded-lg bg-muted/30">
+ <input
+ type="checkbox"
+ id="finalSubmission"
+ checked={isFinalSubmission}
+ onChange={(e) => setIsFinalSubmission(e.target.checked)}
+ disabled={isSubmitting || isSavingDraft}
+ className="h-4 w-4 rounded border-gray-300"
+ />
+ <label htmlFor="finalSubmission" className="text-sm font-medium cursor-pointer">
+ 최종 제출 (체크 시 제출 후 수정 및 취소 불가)
+ </label>
+ </div>
+ )}
+
{/* 응찰 제출 버튼 - 참여 확정 상태일 때만 표시 */}
- <div className="flex justify-end pt-4 gap-2">
- <Button
- variant="outline"
- onClick={handleSaveDraft}
- disabled={isSavingDraft || isSubmitting}
- className="min-w-[100px]"
- >
- <Save className="w-4 h-4 mr-2" />
- {isSavingDraft ? '저장 중...' : '임시 저장'}
- </Button>
- <Button
- onClick={handleSubmitResponse}
- disabled={isSubmitting || isSavingDraft || !!biddingDetail.responseSubmittedAt}
- className="min-w-[100px]"
- >
- <Send className="w-4 h-4 mr-2" />
- {isSubmitting ? '제출 중...' : biddingDetail.responseSubmittedAt ? '응찰 완료' : '응찰 제출'}
- </Button>
+ <div className="flex justify-between pt-4 gap-2">
+ {/* 응찰 취소 버튼 (최종제출 아닌 경우만) */}
+ {biddingDetail.finalQuoteSubmittedAt && !biddingDetail.isFinalSubmission && (
+ <Button
+ variant="destructive"
+ onClick={handleCancelResponse}
+ disabled={isCancelling || isSubmitting}
+ className="min-w-[100px]"
+ >
+ <Trash2 className="w-4 h-4 mr-2" />
+ {isCancelling ? '취소 중...' : '응찰 취소'}
+ </Button>
+ )}
+ <div className="flex gap-2 ml-auto">
+ <Button
+ variant="outline"
+ onClick={handleSaveDraft}
+ disabled={isSavingDraft || isSubmitting || biddingDetail.isFinalSubmission}
+ className="min-w-[100px]"
+ >
+ <Save className="w-4 h-4 mr-2" />
+ {isSavingDraft ? '저장 중...' : '임시 저장'}
+ </Button>
+ <Button
+ onClick={handleSubmitResponse}
+ disabled={isSubmitting || isSavingDraft || biddingDetail.isFinalSubmission}
+ className="min-w-[100px]"
+ >
+ <Send className="w-4 h-4 mr-2" />
+ {isSubmitting ? '제출 중...' : biddingDetail.isFinalSubmission ? '최종 제출 완료' : '응찰 제출'}
+ </Button>
+ </div>
</div>
</CardContent>
</Card>
diff --git a/lib/bidding/vendor/partners-bidding-list-columns.tsx b/lib/bidding/vendor/partners-bidding-list-columns.tsx
index 7fb62122..5870067a 100644
--- a/lib/bidding/vendor/partners-bidding-list-columns.tsx
+++ b/lib/bidding/vendor/partners-bidding-list-columns.tsx
@@ -17,7 +17,6 @@ import {
MoreHorizontal,
Calendar,
User,
- Calculator,
Paperclip,
AlertTriangle
} from 'lucide-react'
@@ -25,6 +24,7 @@ import { formatDate } from '@/lib/utils'
import { biddingStatusLabels, contractTypeLabels } from '@/db/schema'
import { PartnersBiddingListItem } from '../detail/service'
import { Checkbox } from '@/components/ui/checkbox'
+import { toast } from 'sonner'
const columnHelper = createColumnHelper<PartnersBiddingListItem>()
@@ -62,11 +62,15 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
header: '입찰 No.',
cell: ({ row }) => {
const biddingNumber = row.original.biddingNumber
+ const originalBiddingNumber = row.original.originalBiddingNumber
const revision = row.original.revision
return (
<div className="font-mono text-sm">
<div>{biddingNumber}</div>
- <div className="text-muted-foreground">Rev. {revision ?? 0}</div>
+ <div className="text-muted-foreground text-xs">Rev. {revision ?? 0}</div>
+ {originalBiddingNumber && (
+ <div className="text-xs text-muted-foreground">원: {originalBiddingNumber}</div>
+ )}
</div>
)
},
@@ -148,27 +152,36 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
id: 'actions',
header: '액션',
cell: ({ row }) => {
- const handleView = () => {
- if (setRowAction) {
- setRowAction({
- type: 'view',
- row: { original: row.original }
+ // 사양설명회 참석여부 체크 함수
+ const checkSpecificationMeeting = () => {
+ const hasSpecMeeting = row.original.hasSpecificationMeeting
+ const isAttending = row.original.isAttendingMeeting
+
+ // 사양설명회가 있고, 참석여부가 아직 설정되지 않은 경우
+ if (hasSpecMeeting && isAttending === null) {
+ toast.warning('사양설명회 참석여부 필요', {
+ description: '사전견적 또는 입찰을 진행하기 전에 사양설명회 참석여부를 먼저 설정해주세요.',
+ duration: 5000,
})
+ return false
}
+ return true
}
- const handlePreQuote = () => {
+ const handleView = () => {
+ // 사양설명회 체크
+ if (!checkSpecificationMeeting()) {
+ return
+ }
+
if (setRowAction) {
setRowAction({
- type: 'pre-quote',
+ type: 'view',
row: { original: row.original }
})
}
}
- const biddingStatus = row.original.status
- const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
-
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -185,12 +198,6 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
<FileText className="mr-2 h-4 w-4" />
입찰 상세보기
</DropdownMenuItem>
- {!isClosed && (
- <DropdownMenuItem onClick={handlePreQuote}>
- <Calculator className="mr-2 h-4 w-4" />
- 사전견적하기
- </DropdownMenuItem>
- )}
</DropdownMenuContent>
</DropdownMenu>
)
@@ -327,61 +334,50 @@ export function getPartnersBiddingListColumns({ setRowAction }: PartnersBiddingL
const endDate = row.original.contractEndDate
if (!startDate || !endDate) {
- return <div className="max-w-24 truncate">-</div>
+ return <div className="text-muted-foreground text-center">-</div>
}
return (
- <div className="max-w-24 truncate" title={`${formatDate(startDate, 'KR')} ~ ${formatDate(endDate, 'KR')}`}>
- {formatDate(startDate, 'KR')} ~ {formatDate(endDate, 'KR')}
+ <div className="text-sm">
+ <div>{formatDate(startDate, 'KR')}</div>
+ <div className="text-muted-foreground">~</div>
+ <div>{formatDate(endDate, 'KR')}</div>
</div>
)
},
}),
- // 참여회신 마감일
- columnHelper.accessor('responseDeadline', {
- header: '참여회신 마감일',
- cell: ({ row }) => {
- const deadline = row.original.responseDeadline
- if (!deadline) {
- return <div className="text-muted-foreground">-</div>
- }
- return <div className="text-sm">{formatDate(deadline, 'KR')}</div>
- },
- }),
-
- // 입찰제출일
- columnHelper.accessor('submissionDate', {
- header: '입찰제출일',
+ // 입찰담당자
+ columnHelper.display({
+ id: 'bidPicName',
+ header: '입찰담당자',
cell: ({ row }) => {
- const date = row.original.submissionDate
- if (!date) {
- return <div className="text-muted-foreground">-</div>
+ const name = row.original.bidPicName
+ if (!name) {
+ return <div className="text-muted-foreground text-center">-</div>
}
- return <div className="text-sm">{formatDate(date, 'KR')}</div>
+ return (
+ <div className="flex items-center gap-1">
+ <User className="h-4 w-4" />
+ <div className="text-sm">{name}</div>
+ </div>
+ )
},
}),
- // 입찰담당자
- columnHelper.accessor('managerName', {
- header: '입찰담당자',
+ // 조달담당자
+ columnHelper.display({
+ id: 'supplyPicName',
+ header: '조달담당자',
cell: ({ row }) => {
- const name = row.original.managerName
- const email = row.original.managerEmail
+ const name = row.original.supplyPicName
if (!name) {
- return <div className="text-muted-foreground">-</div>
+ return <div className="text-muted-foreground text-center">-</div>
}
return (
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
- <div>
- <div className="text-sm">{name}</div>
- {email && (
- <div className="text-xs text-muted-foreground truncate max-w-32" title={email}>
- {email}
- </div>
- )}
- </div>
+ <div className="text-sm">{name}</div>
</div>
)
},
diff --git a/lib/bidding/vendor/partners-bidding-pre-quote.tsx b/lib/bidding/vendor/partners-bidding-pre-quote.tsx
deleted file mode 100644
index 8a157c5f..00000000
--- a/lib/bidding/vendor/partners-bidding-pre-quote.tsx
+++ /dev/null
@@ -1,1413 +0,0 @@
-'use client'
-
-import * as React from 'react'
-import { useRouter } from 'next/navigation'
-import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Textarea } from '@/components/ui/textarea'
-import { Checkbox } from '@/components/ui/checkbox'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import {
- ArrowLeft,
- Calendar,
- Building2,
- Package,
- User,
- FileText,
- Users,
- Send,
- CheckCircle,
- XCircle,
- Save
-} from 'lucide-react'
-
-import { formatDate } from '@/lib/utils'
-import {
- getBiddingCompaniesForPartners,
- submitPreQuoteResponse,
- getPrItemsForBidding,
- getSavedPrItemQuotations,
- savePreQuoteDraft,
- setPreQuoteParticipation
-} from '../pre-quote/service'
-import { getBiddingConditions } from '../service'
-import { getPriceAdjustmentFormByBiddingCompanyId } from '../detail/service'
-import { getIncotermsForSelection, getPaymentTermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from '@/lib/procurement-select/service'
-import { TAX_CONDITIONS, getTaxConditionName } from '@/lib/tax-conditions/types'
-import { PrItemsPricingTable } from './components/pr-items-pricing-table'
-import { SimpleFileUpload } from './components/simple-file-upload'
-import {
- biddingStatusLabels,
-} from '@/db/schema'
-import { useToast } from '@/hooks/use-toast'
-import { useTransition } from 'react'
-import { useSession } from 'next-auth/react'
-
-interface PartnersBiddingPreQuoteProps {
- biddingId: number
- companyId: number
-}
-
-interface BiddingDetail {
- id: number
- biddingNumber: string
- revision: number | null
- projectName: string | null
- itemName: string | null
- title: string
- description: string | null
- content: string | null
- contractType: string
- biddingType: string
- awardCount: string
- contractStartDate: Date | null
- contractEndDate: Date | null
- preQuoteDate: string | null
- biddingRegistrationDate: string | null
- submissionStartDate: string | null
- submissionEndDate: string | null
- evaluationDate: string | null
- currency: string
- budget: number | null
- targetPrice: number | null
- status: string
- managerName: string | null
- managerEmail: string | null
- managerPhone: string | null
- biddingCompanyId: number | null
- biddingId: number // bidding의 ID 추가
- invitationStatus: string | null
- preQuoteAmount: string | null
- preQuoteSubmittedAt: string | null
- preQuoteDeadline: string | null
- isPreQuoteSelected: boolean | null
- isAttendingMeeting: boolean | null
- // companyConditionResponses에서 가져온 조건들 (제시된 조건과 응답 모두)
- paymentTermsResponse: string | null
- taxConditionsResponse: string | null
- incotermsResponse: string | null
- proposedContractDeliveryDate: string | null
- proposedShippingPort: string | null
- proposedDestinationPort: string | null
- priceAdjustmentResponse: boolean | null
- sparePartResponse: string | null
- isInitialResponse: boolean | null
- additionalProposals: string | null
-}
-
-export function PartnersBiddingPreQuote({ biddingId, companyId }: PartnersBiddingPreQuoteProps) {
- const router = useRouter()
- const { toast } = useToast()
- const [isPending, startTransition] = useTransition()
- const session = useSession()
- const [biddingDetail, setBiddingDetail] = React.useState<BiddingDetail | null>(null)
- const [isLoading, setIsLoading] = React.useState(true)
- const [biddingConditions, setBiddingConditions] = React.useState<any | null>(null)
-
- // Procurement 데이터 상태들
- const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [incotermsOptions, setIncotermsOptions] = React.useState<Array<{code: string, description: string}>>([])
- const [shippingPlaces, setShippingPlaces] = React.useState<Array<{code: string, description: string}>>([])
- const [destinationPlaces, setDestinationPlaces] = React.useState<Array<{code: string, description: string}>>([])
-
- // 품목별 견적 관련 상태
- const [prItems, setPrItems] = React.useState<any[]>([])
- const [prItemQuotations, setPrItemQuotations] = React.useState<any[]>([])
- const [totalAmount, setTotalAmount] = React.useState(0)
- const [isSaving, setIsSaving] = React.useState(false)
-
- // 사전견적 폼 상태
- const [responseData, setResponseData] = React.useState({
- preQuoteAmount: '',
- paymentTermsResponse: '',
- taxConditionsResponse: '',
- incotermsResponse: '',
- proposedContractDeliveryDate: '',
- proposedShippingPort: '',
- proposedDestinationPort: '',
- priceAdjustmentResponse: false,
- isInitialResponse: false,
- sparePartResponse: '',
- additionalProposals: '',
- isAttendingMeeting: false,
- })
-
- // 사전견적 참여의사 상태
- const [participationDecision, setParticipationDecision] = React.useState<boolean | null>(null)
-
- // 연동제 폼 상태
- const [priceAdjustmentForm, setPriceAdjustmentForm] = React.useState({
- itemName: '',
- adjustmentReflectionPoint: '',
- majorApplicableRawMaterial: '',
- adjustmentFormula: '',
- rawMaterialPriceIndex: '',
- referenceDate: '',
- comparisonDate: '',
- adjustmentRatio: '',
- notes: '',
- adjustmentConditions: '',
- majorNonApplicableRawMaterial: '',
- adjustmentPeriod: '',
- contractorWriter: '',
- adjustmentDate: '',
- nonApplicableReason: '',
- })
- const userId = session.data?.user?.id || ''
-
- // Procurement 데이터 로드 함수들
- const loadPaymentTerms = React.useCallback(async () => {
- try {
- const data = await getPaymentTermsForSelection();
- setPaymentTermsOptions(data);
- } catch (error) {
- console.error("Failed to load payment terms:", error);
- }
- }, []);
-
- const loadIncoterms = React.useCallback(async () => {
- try {
- const data = await getIncotermsForSelection();
- setIncotermsOptions(data);
- } catch (error) {
- console.error("Failed to load incoterms:", error);
- }
- }, []);
-
- const loadShippingPlaces = React.useCallback(async () => {
- try {
- const data = await getPlaceOfShippingForSelection();
- setShippingPlaces(data);
- } catch (error) {
- console.error("Failed to load shipping places:", error);
- }
- }, []);
-
- const loadDestinationPlaces = React.useCallback(async () => {
- try {
- const data = await getPlaceOfDestinationForSelection();
- setDestinationPlaces(data);
- } catch (error) {
- console.error("Failed to load destination places:", error);
- }
- }, []);
-
- // 데이터 로드
- React.useEffect(() => {
- const loadData = async () => {
- try {
- setIsLoading(true)
-
- // 모든 필요한 데이터를 병렬로 로드
- const [result, conditions, prItemsData] = await Promise.all([
- getBiddingCompaniesForPartners(biddingId, companyId),
- getBiddingConditions(biddingId),
- getPrItemsForBidding(biddingId)
- ])
-
- if (result) {
- setBiddingDetail(result as BiddingDetail)
-
- // 저장된 품목별 견적 정보가 있으면 로드
- if (result.biddingCompanyId) {
- const savedQuotations = await getSavedPrItemQuotations(result.biddingCompanyId)
- setPrItemQuotations(savedQuotations)
-
- // 총 금액 계산
- const calculatedTotal = savedQuotations.reduce((sum: number, item: any) => sum + item.bidAmount, 0)
- setTotalAmount(calculatedTotal)
-
- // 저장된 연동제 정보가 있으면 로드
- if (result.priceAdjustmentResponse) {
- const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(result.biddingCompanyId)
- if (savedPriceAdjustmentForm) {
- setPriceAdjustmentForm({
- itemName: savedPriceAdjustmentForm.itemName || '',
- adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '',
- majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '',
- adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '',
- rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '',
- referenceDate: savedPriceAdjustmentForm.referenceDate ? new Date(savedPriceAdjustmentForm.referenceDate).toISOString().split('T')[0] : '',
- comparisonDate: savedPriceAdjustmentForm.comparisonDate ? new Date(savedPriceAdjustmentForm.comparisonDate).toISOString().split('T')[0] : '',
- adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio?.toString() || '',
- notes: savedPriceAdjustmentForm.notes || '',
- adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '',
- majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '',
- adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '',
- contractorWriter: savedPriceAdjustmentForm.contractorWriter || '',
- adjustmentDate: savedPriceAdjustmentForm.adjustmentDate ? new Date(savedPriceAdjustmentForm.adjustmentDate).toISOString().split('T')[0] : '',
- nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '',
- })
- }
- }
- }
-
- // 기존 응답 데이터로 폼 초기화
- setResponseData({
- preQuoteAmount: result.preQuoteAmount?.toString() || '',
- paymentTermsResponse: result.paymentTermsResponse || '',
- taxConditionsResponse: result.taxConditionsResponse || '',
- incotermsResponse: result.incotermsResponse || '',
- proposedContractDeliveryDate: result.proposedContractDeliveryDate || '',
- proposedShippingPort: result.proposedShippingPort || '',
- proposedDestinationPort: result.proposedDestinationPort || '',
- priceAdjustmentResponse: result.priceAdjustmentResponse || false,
- isInitialResponse: result.isInitialResponse || false,
- sparePartResponse: result.sparePartResponse || '',
- additionalProposals: result.additionalProposals || '',
- isAttendingMeeting: result.isAttendingMeeting || false,
- })
-
- // 사전견적 참여의사 초기화
- setParticipationDecision(result.isPreQuoteParticipated)
- }
-
- if (conditions) {
- // BiddingConditionsEdit와 같은 방식으로 raw 데이터 사용
- setBiddingConditions(conditions)
- }
-
- if (prItemsData) {
- setPrItems(prItemsData)
- }
-
- // Procurement 데이터 로드
- await Promise.all([
- loadPaymentTerms(),
- loadIncoterms(),
- loadShippingPlaces(),
- loadDestinationPlaces()
- ])
- } catch (error) {
- console.error('Failed to load bidding company:', error)
- toast({
- title: '오류',
- description: '입찰 정보를 불러오는데 실패했습니다.',
- variant: 'destructive',
- })
- } finally {
- setIsLoading(false)
- }
- }
-
- loadData()
- }, [biddingId, companyId, toast, loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces])
-
- // 임시저장 기능
- const handleTempSave = () => {
- if (!biddingDetail || !biddingDetail.biddingCompanyId) {
- toast({
- title: '임시저장 실패',
- description: '입찰 정보가 올바르지 않습니다.',
- variant: 'destructive',
- })
- return
- }
- // 입찰 마감 상태 체크
- const biddingStatus = biddingDetail.status
- const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
-
- if (isClosed) {
- toast({
- title: "접근 제한",
- description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.",
- variant: "destructive",
- })
- router.back()
- return
- }
-
- // 사전견적 상태 체크
- const isPreQuoteStatus = biddingStatus === 'request_for_quotation' || biddingStatus === 'received_quotation'
- if (!isPreQuoteStatus) {
- toast({
- title: "접근 제한",
- description: "사전견적 단계가 아니므로 임시저장이 불가능합니다.",
- variant: "destructive",
- })
- return
- }
-
- if (!userId) {
- toast({
- title: '임시저장 실패',
- description: '사용자 정보를 확인할 수 없습니다. 다시 로그인해주세요.',
- variant: 'destructive',
- })
- return
- }
-
- setIsSaving(true)
- startTransition(async () => {
- try {
- const result = await savePreQuoteDraft(
- biddingDetail.biddingCompanyId!,
- {
- prItemQuotations,
- paymentTermsResponse: responseData.paymentTermsResponse,
- taxConditionsResponse: responseData.taxConditionsResponse,
- incotermsResponse: responseData.incotermsResponse,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
- proposedShippingPort: responseData.proposedShippingPort,
- proposedDestinationPort: responseData.proposedDestinationPort,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse || false, // 체크 안하면 false로 설정
- isInitialResponse: responseData.isInitialResponse || false, // 체크 안하면 false로 설정
- sparePartResponse: responseData.sparePartResponse,
- additionalProposals: responseData.additionalProposals,
- priceAdjustmentForm: (responseData.priceAdjustmentResponse || false) ? {
- itemName: priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: priceAdjustmentForm.referenceDate,
- comparisonDate: priceAdjustmentForm.comparisonDate,
- adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined,
- notes: priceAdjustmentForm.notes,
- adjustmentConditions: priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: priceAdjustmentForm.contractorWriter,
- adjustmentDate: priceAdjustmentForm.adjustmentDate,
- nonApplicableReason: priceAdjustmentForm.nonApplicableReason,
- } : undefined
- },
- userId
- )
-
- if (result.success) {
- toast({
- title: '임시저장 완료',
- description: result.message,
- })
- } else {
- toast({
- title: '임시저장 실패',
- description: result.error,
- variant: 'destructive',
- })
- }
- } catch (error) {
- console.error('Temp save error:', error)
- toast({
- title: '임시저장 실패',
- description: '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
- variant: 'destructive',
- })
- } finally {
- setIsSaving(false)
- }
- })
- }
-
- // 사전견적 참여의사 설정 함수
- const handleParticipationDecision = async (participate: boolean) => {
- if (!biddingDetail?.biddingCompanyId) return
-
- startTransition(async () => {
- const result = await setPreQuoteParticipation(
- biddingDetail.biddingCompanyId!,
- participate
- )
-
- if (result.success) {
- setParticipationDecision(participate)
- toast({
- title: '설정 완료',
- description: `사전견적 ${participate ? '참여' : '미참여'}로 설정되었습니다.`,
- })
- } else {
- toast({
- title: '설정 실패',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
- const handleSubmitResponse = () => {
- if (!biddingDetail) return
-
- // 입찰 마감 상태 체크
- const biddingStatus = biddingDetail.status
- const isClosed = biddingStatus === 'bidding_closed' || biddingStatus === 'vendor_selected' || biddingStatus === 'bidding_disposal'
-
- if (isClosed) {
- toast({
- title: "접근 제한",
- description: "입찰이 마감되어 더 이상 사전견적을 제출할 수 없습니다.",
- variant: "destructive",
- })
- router.back()
- return
- }
-
- // 사전견적 상태 체크
- const isPreQuoteStatus = biddingStatus === 'request_for_quotation' || biddingStatus === 'received_quotation'
- if (!isPreQuoteStatus) {
- toast({
- title: "접근 제한",
- description: "사전견적 단계가 아니므로 견적 제출이 불가능합니다.",
- variant: "destructive",
- })
- return
- }
-
- // 견적마감일 체크
- if (biddingDetail.preQuoteDeadline) {
- const now = new Date()
- const deadline = new Date(biddingDetail.preQuoteDeadline)
- if (deadline < now) {
- toast({
- title: '견적 마감',
- description: '견적 마감일이 지나 제출할 수 없습니다.',
- variant: 'destructive',
- })
- return
- }
- }
-
- // 필수값 검증
- if (prItemQuotations.length === 0 || totalAmount === 0) {
- toast({
- title: '유효성 오류',
- description: '품목별 견적을 입력해주세요.',
- variant: 'destructive',
- })
- return
- }
-
- // 품목별 납품일 검증
- if (prItemQuotations.length > 0) {
- for (const quotation of prItemQuotations) {
- if (!quotation.proposedDeliveryDate?.trim()) {
- const prItem = prItems.find(item => item.id === quotation.prItemId)
- toast({
- title: '유효성 오류',
- description: `품목 ${prItem?.itemNumber || quotation.prItemId}의 납품예정일을 입력해주세요.`,
- variant: 'destructive',
- })
- return
- }
- }
- }
-
- const requiredFields = [
- { value: responseData.proposedContractDeliveryDate, name: '제안 납품일' },
- { value: responseData.paymentTermsResponse, name: '응답 지급조건' },
- { value: responseData.taxConditionsResponse, name: '응답 세금조건' },
- { value: responseData.incotermsResponse, name: '응답 운송조건' },
- { value: responseData.proposedShippingPort, name: '제안 선적지' },
- { value: responseData.proposedDestinationPort, name: '제안 하역지' },
- { value: responseData.sparePartResponse, name: '스페어파트 응답' },
- ]
-
- const missingField = requiredFields.find(field => !field.value?.trim())
- if (missingField) {
- toast({
- title: '유효성 오류',
- description: `${missingField.name}을(를) 입력해주세요.`,
- variant: 'destructive',
- })
- return
- }
-
- startTransition(async () => {
- const submissionData = {
- preQuoteAmount: totalAmount, // 품목별 계산된 총 금액 사용
- prItemQuotations, // 품목별 견적 데이터 추가
- paymentTermsResponse: responseData.paymentTermsResponse,
- taxConditionsResponse: responseData.taxConditionsResponse,
- incotermsResponse: responseData.incotermsResponse,
- proposedContractDeliveryDate: responseData.proposedContractDeliveryDate,
- proposedShippingPort: responseData.proposedShippingPort,
- proposedDestinationPort: responseData.proposedDestinationPort,
- priceAdjustmentResponse: responseData.priceAdjustmentResponse || false, // 체크 안하면 false로 설정
- isInitialResponse: responseData.isInitialResponse || false, // 체크 안하면 false로 설정
- sparePartResponse: responseData.sparePartResponse,
- additionalProposals: responseData.additionalProposals,
- priceAdjustmentForm: (responseData.priceAdjustmentResponse || false) ? {
- itemName: priceAdjustmentForm.itemName,
- adjustmentReflectionPoint: priceAdjustmentForm.adjustmentReflectionPoint,
- majorApplicableRawMaterial: priceAdjustmentForm.majorApplicableRawMaterial,
- adjustmentFormula: priceAdjustmentForm.adjustmentFormula,
- rawMaterialPriceIndex: priceAdjustmentForm.rawMaterialPriceIndex,
- referenceDate: priceAdjustmentForm.referenceDate,
- comparisonDate: priceAdjustmentForm.comparisonDate,
- adjustmentRatio: priceAdjustmentForm.adjustmentRatio ? parseFloat(priceAdjustmentForm.adjustmentRatio) : undefined,
- notes: priceAdjustmentForm.notes,
- adjustmentConditions: priceAdjustmentForm.adjustmentConditions,
- majorNonApplicableRawMaterial: priceAdjustmentForm.majorNonApplicableRawMaterial,
- adjustmentPeriod: priceAdjustmentForm.adjustmentPeriod,
- contractorWriter: priceAdjustmentForm.contractorWriter,
- adjustmentDate: priceAdjustmentForm.adjustmentDate,
- nonApplicableReason: priceAdjustmentForm.nonApplicableReason,
- } : undefined
- }
-
- const result = await submitPreQuoteResponse(
- biddingDetail.biddingCompanyId!,
- submissionData,
- userId
- )
-
- console.log('제출 결과:', result)
-
- if (result.success) {
- toast({
- title: '성공',
- description: result.message,
- })
-
- // 데이터 새로고침 및 폼 상태 업데이트
- const updatedDetail = await getBiddingCompaniesForPartners(biddingId, companyId)
- console.log('업데이트된 데이터:', updatedDetail)
-
- if (updatedDetail) {
- setBiddingDetail(updatedDetail as BiddingDetail)
-
- // 폼 상태도 업데이트된 데이터로 다시 설정
- setResponseData({
- preQuoteAmount: updatedDetail.preQuoteAmount?.toString() || '',
- paymentTermsResponse: updatedDetail.paymentTermsResponse || '',
- taxConditionsResponse: updatedDetail.taxConditionsResponse || '',
- incotermsResponse: updatedDetail.incotermsResponse || '',
- proposedContractDeliveryDate: updatedDetail.proposedContractDeliveryDate || '',
- proposedShippingPort: updatedDetail.proposedShippingPort || '',
- proposedDestinationPort: updatedDetail.proposedDestinationPort || '',
- priceAdjustmentResponse: updatedDetail.priceAdjustmentResponse || false,
- isInitialResponse: updatedDetail.isInitialResponse || false,
- sparePartResponse: updatedDetail.sparePartResponse || '',
- additionalProposals: updatedDetail.additionalProposals || '',
- isAttendingMeeting: updatedDetail.isAttendingMeeting || false,
- })
-
- // 연동제 데이터도 다시 로드
- if (updatedDetail.biddingCompanyId && updatedDetail.priceAdjustmentResponse) {
- const savedPriceAdjustmentForm = await getPriceAdjustmentFormByBiddingCompanyId(updatedDetail.biddingCompanyId)
- if (savedPriceAdjustmentForm) {
- setPriceAdjustmentForm({
- itemName: savedPriceAdjustmentForm.itemName || '',
- adjustmentReflectionPoint: savedPriceAdjustmentForm.adjustmentReflectionPoint || '',
- majorApplicableRawMaterial: savedPriceAdjustmentForm.majorApplicableRawMaterial || '',
- adjustmentFormula: savedPriceAdjustmentForm.adjustmentFormula || '',
- rawMaterialPriceIndex: savedPriceAdjustmentForm.rawMaterialPriceIndex || '',
- referenceDate: savedPriceAdjustmentForm.referenceDate ? new Date(savedPriceAdjustmentForm.referenceDate).toISOString().split('T')[0] : '',
- comparisonDate: savedPriceAdjustmentForm.comparisonDate ? new Date(savedPriceAdjustmentForm.comparisonDate).toISOString().split('T')[0] : '',
- adjustmentRatio: savedPriceAdjustmentForm.adjustmentRatio?.toString() || '',
- notes: savedPriceAdjustmentForm.notes || '',
- adjustmentConditions: savedPriceAdjustmentForm.adjustmentConditions || '',
- majorNonApplicableRawMaterial: savedPriceAdjustmentForm.majorNonApplicableRawMaterial || '',
- adjustmentPeriod: savedPriceAdjustmentForm.adjustmentPeriod || '',
- contractorWriter: savedPriceAdjustmentForm.contractorWriter || '',
- adjustmentDate: savedPriceAdjustmentForm.adjustmentDate ? new Date(savedPriceAdjustmentForm.adjustmentDate).toISOString().split('T')[0] : '',
- nonApplicableReason: savedPriceAdjustmentForm.nonApplicableReason || '',
- })
- }
- }
- }
- } else {
- toast({
- title: '오류',
- description: result.error,
- variant: 'destructive',
- })
- }
- })
- }
-
-
- if (isLoading) {
- return (
- <div className="flex items-center justify-center py-12">
- <div className="text-center">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
- <p className="text-muted-foreground">입찰 정보를 불러오는 중...</p>
- </div>
- </div>
- )
- }
-
- if (!biddingDetail) {
- return (
- <div className="text-center py-12">
- <p className="text-muted-foreground">입찰 정보를 찾을 수 없습니다.</p>
- <Button onClick={() => router.back()} className="mt-4">
- <ArrowLeft className="w-4 h-4 mr-2" />
- 돌아가기
- </Button>
- </div>
- )
- }
-
- return (
- <div className="space-y-6">
- {/* 헤더 */}
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-4">
- <Button variant="outline" onClick={() => router.back()}>
- <ArrowLeft className="w-4 h-4 mr-2" />
- 목록으로
- </Button>
- <div>
- <h1 className="text-2xl font-semibold">{biddingDetail.title}</h1>
- <div className="flex items-center gap-2 mt-1">
- <Badge variant="outline" className="font-mono">
- {biddingDetail.biddingNumber}
- {biddingDetail.revision && biddingDetail.revision > 0 && ` Rev.${biddingDetail.revision}`}
- </Badge>
- <Badge variant={
- biddingDetail.status === 'bidding_disposal' ? 'destructive' :
- biddingDetail.status === 'vendor_selected' ? 'default' :
- 'secondary'
- }>
- {biddingStatusLabels[biddingDetail.status]}
- </Badge>
- </div>
- </div>
- </div>
-
- </div>
-
- {/* 입찰 공고 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- 입찰 공고
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div>
- <Label className="text-sm font-medium text-muted-foreground">프로젝트</Label>
- <div className="flex items-center gap-2 mt-1">
- <Building2 className="w-4 h-4" />
- <span>{biddingDetail.projectName}</span>
- </div>
- </div>
- <div>
- <Label className="text-sm font-medium text-muted-foreground">품목</Label>
- <div className="flex items-center gap-2 mt-1">
- <Package className="w-4 h-4" />
- <span>{biddingDetail.itemName}</span>
- </div>
- </div>
- {/* <div>
- <Label className="text-sm font-medium text-muted-foreground">계약구분</Label>
- <div className="mt-1">{contractTypeLabels[biddingDetail.contractType]}</div>
- </div>
- <div>
- <Label className="text-sm font-medium text-muted-foreground">입찰유형</Label>
- <div className="mt-1">{biddingTypeLabels[biddingDetail.biddingType]}</div>
- </div>
- <div>
- <Label className="text-sm font-medium text-muted-foreground">낙찰수</Label>
- <div className="mt-1">{biddingDetail.awardCount === 'single' ? '단수' : '복수'}</div>
- </div> */}
- <div>
- <Label className="text-sm font-medium text-muted-foreground">담당자</Label>
- <div className="flex items-center gap-2 mt-1">
- <User className="w-4 h-4" />
- <span>{biddingDetail.managerName}</span>
- </div>
- </div>
- </div>
-
- {/* {biddingDetail.budget && (
- <div>
- <Label className="text-sm font-medium text-muted-foreground">예산</Label>
- <div className="flex items-center gap-2 mt-1">
- <DollarSign className="w-4 h-4" />
- <span className="font-semibold">{formatCurrency(biddingDetail.budget)}</span>
- </div>
- </div>
- )} */}
-
- {/* 일정 정보 */}
- {/* <div className="pt-4 border-t">
- <Label className="text-sm font-medium text-muted-foreground mb-2 block">일정 정보</Label>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
- {biddingDetail.submissionStartDate && biddingDetail.submissionEndDate && (
- <div>
- <span className="font-medium">제출기간:</span> {formatDate(biddingDetail.submissionStartDate, 'KR')} ~ {formatDate(biddingDetail.submissionEndDate, 'KR')}
- </div>
- )}
- {biddingDetail.evaluationDate && (
- <div>
- <span className="font-medium">평가일:</span> {formatDate(biddingDetail.evaluationDate, 'KR')}
- </div>
- )}
- </div>
- </div> */}
-
- {/* 견적마감일 정보 */}
- {biddingDetail.preQuoteDeadline && (
- <div className="pt-4 border-t">
- <Label className="text-sm font-medium text-muted-foreground mb-2 block">견적 마감 정보</Label>
- {(() => {
- const now = new Date()
- const deadline = new Date(biddingDetail.preQuoteDeadline)
- const isExpired = deadline < now
- const timeLeft = deadline.getTime() - now.getTime()
- const daysLeft = Math.floor(timeLeft / (1000 * 60 * 60 * 24))
- const hoursLeft = Math.floor((timeLeft % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
-
- return (
- <div className={`p-3 rounded-lg border-2 ${
- isExpired
- ? 'border-red-200 bg-red-50'
- : daysLeft <= 1
- ? 'border-orange-200 bg-orange-50'
- : 'border-green-200 bg-green-50'
- }`}>
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <Calendar className="w-5 h-5" />
- <span className="font-medium">견적 마감일:</span>
- <span className="text-lg font-semibold">
- {formatDate(biddingDetail.preQuoteDeadline, 'KR')}
- </span>
- </div>
- {isExpired ? (
- <Badge variant="destructive" className="ml-2">
- 마감됨
- </Badge>
- ) : daysLeft <= 1 ? (
- <Badge variant="secondary" className="ml-2 bg-orange-100 text-orange-800">
- {daysLeft === 0 ? `${hoursLeft}시간 남음` : `${daysLeft}일 남음`}
- </Badge>
- ) : (
- <Badge variant="secondary" className="ml-2 bg-green-100 text-green-800">
- {daysLeft}일 남음
- </Badge>
- )}
- </div>
- {isExpired && (
- <div className="mt-2 text-sm text-red-600">
- ⚠️ 견적 마감일이 지났습니다. 견적 제출이 불가능합니다.
- </div>
- )}
- </div>
- )
- })()}
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 현재 설정된 조건 섹션 */}
- {biddingConditions && (
- <Card>
- <CardHeader>
- <CardTitle>현재 설정된 입찰 조건</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
- <div>
- <Label className="text-muted-foreground">지급조건</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.paymentTerms || "미설정"}</p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">세금조건</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">
- {biddingConditions.taxConditions
- ? getTaxConditionName(biddingConditions.taxConditions)
- : "미설정"
- }
- </p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">운송조건</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.incoterms || "미설정"}</p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">계약 납기일</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">
- {biddingConditions.contractDeliveryDate
- ? formatDate(biddingConditions.contractDeliveryDate, 'KR')
- : "미설정"
- }
- </p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">선적지</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.shippingPort || "미설정"}</p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">하역지</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.destinationPort || "미설정"}</p>
- </div>
- </div>
-
- <div>
- <Label className="text-muted-foreground">연동제 적용</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.isPriceAdjustmentApplicable ? "적용 가능" : "적용 불가"}</p>
- </div>
- </div>
-
-
- <div >
- <Label className="text-muted-foreground">스페어파트 옵션</Label>
- <div className="mt-1 p-3 bg-muted rounded-md">
- <p className="font-medium">{biddingConditions.sparePartOptions}</p>
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
- )}
-
- {/* 사전견적 참여의사 결정 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Users className="w-5 h-5" />
- 사전견적 참여의사 결정
- </CardTitle>
- </CardHeader>
- <CardContent>
- {participationDecision === null ? (
- <div className="space-y-4">
- <p className="text-muted-foreground">
- 해당 입찰의 사전견적에 참여하시겠습니까?
- </p>
- <div className="flex gap-3">
- <Button
- onClick={() => handleParticipationDecision(true)}
- disabled={isPending}
- className="flex items-center gap-2"
- >
- <CheckCircle className="w-4 h-4" />
- 참여
- </Button>
- <Button
- variant="outline"
- onClick={() => handleParticipationDecision(false)}
- disabled={isPending}
- className="flex items-center gap-2"
- >
- <XCircle className="w-4 h-4" />
- 미참여
- </Button>
- </div>
- </div>
- ) : (
- <div className="space-y-4">
- <div className={`flex items-center gap-2 p-3 rounded-lg ${
- participationDecision ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
- }`}>
- {participationDecision ? (
- <CheckCircle className="w-5 h-5" />
- ) : (
- <XCircle className="w-5 h-5" />
- )}
- <span className="font-medium">
- 사전견적 {participationDecision ? '참여' : '미참여'}로 설정되었습니다.
- </span>
- </div>
- {participationDecision === false && (
- <>
- <div className="p-4 bg-muted rounded-lg">
- <p className="text-muted-foreground">
- 미참여로 설정되어 견적 작성 섹션이 숨겨집니다. 참여하시려면 아래 버튼을 클릭해주세요.
- </p>
- </div>
-
- <Button
- variant="outline"
- size="sm"
- onClick={() => setParticipationDecision(null)}
- disabled={isPending}
- >
- 결정 변경하기
- </Button>
- </>
- )}
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 참여 결정 시에만 견적 작성 섹션들 표시 (단, 견적마감일이 지나지 않은 경우에만) */}
- {participationDecision === true && (() => {
- // 견적마감일 체크
- if (biddingDetail?.preQuoteDeadline) {
- const now = new Date()
- const deadline = new Date(biddingDetail.preQuoteDeadline)
- const isExpired = deadline < now
-
- if (isExpired) {
- return (
- <Card>
- <CardContent className="pt-6">
- <div className="text-center py-8">
- <XCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
- <h3 className="text-lg font-semibold text-red-700 mb-2">견적 마감</h3>
- <p className="text-muted-foreground">
- 견적 마감일({formatDate(biddingDetail.preQuoteDeadline, 'KR')})이 지나 견적 제출이 불가능합니다.
- </p>
- </div>
- </CardContent>
- </Card>
- )
- }
- }
-
- return true // 견적 작성 가능
- })() && (
- <>
- {/* 품목별 견적 작성 섹션 */}
- {prItems.length > 0 && (
- <PrItemsPricingTable
- prItems={prItems}
- initialQuotations={prItemQuotations}
- currency={biddingDetail?.currency || 'KRW'}
- onQuotationsChange={setPrItemQuotations}
- onTotalAmountChange={setTotalAmount}
- readOnly={false}
- />
- )}
-
- {/* 견적 문서 업로드 섹션 */}
- <SimpleFileUpload
- biddingId={biddingId}
- companyId={companyId}
- userId={userId}
- readOnly={false}
- />
-
- {/* 사전견적 폼 섹션 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Send className="w-5 h-5" />
- 사전견적 제출하기
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* 총 금액 표시 (읽기 전용) */}
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="totalAmount">총 사전견적 금액 <span className="text-red-500">*</span></Label>
- <Input
- id="totalAmount"
- type="text"
- value={new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: biddingDetail?.currency || 'KRW',
- }).format(totalAmount)}
- readOnly
- className="bg-gray-50 font-semibold text-primary"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="proposedContractDeliveryDate">제안 납품일 <span className="text-red-500">*</span></Label>
- <Input
- id="proposedContractDeliveryDate"
- type="date"
- value={responseData.proposedContractDeliveryDate}
- onChange={(e) => setResponseData({...responseData, proposedContractDeliveryDate: e.target.value})}
- title={biddingConditions?.contractDeliveryDate ? `참고 납기일: ${formatDate(biddingConditions.contractDeliveryDate, 'KR')}` : "납품일을 선택하세요"}
- />
- {biddingConditions?.contractDeliveryDate && (
- <p className="text-xs text-muted-foreground">
- 참고 납기일: {formatDate(biddingConditions.contractDeliveryDate, 'KR')}
- </p>
- )}
- </div>
- </div>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="paymentTermsResponse">응답 지급조건 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.paymentTermsResponse}
- onValueChange={(value) => setResponseData({...responseData, paymentTermsResponse: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.paymentTerms ? `참고: ${biddingConditions.paymentTerms}` : "지급조건 선택"} />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="taxConditionsResponse">응답 세금조건 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.taxConditionsResponse}
- onValueChange={(value) => setResponseData({...responseData, taxConditionsResponse: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.taxConditions ? `참고: ${getTaxConditionName(biddingConditions.taxConditions)}` : "세금조건 선택"} />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code}>
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="incotermsResponse">응답 운송조건 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.incotermsResponse}
- onValueChange={(value) => setResponseData({...responseData, incotermsResponse: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.incoterms ? `참고: ${biddingConditions.incoterms}` : "운송조건 선택"} />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code}>
- {option.code} {option.description && `(${option.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="proposedShippingPort">제안 선적지 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.proposedShippingPort}
- onValueChange={(value) => setResponseData({...responseData, proposedShippingPort: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.shippingPort ? `참고: ${biddingConditions.shippingPort}` : "선적지 선택"} />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- </div>
-
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <Label htmlFor="proposedDestinationPort">제안 하역지 <span className="text-red-500">*</span></Label>
- <Select
- value={responseData.proposedDestinationPort}
- onValueChange={(value) => setResponseData({...responseData, proposedDestinationPort: value})}
- >
- <SelectTrigger>
- <SelectValue placeholder={biddingConditions?.destinationPort ? `참고: ${biddingConditions.destinationPort}` : "하역지 선택"} />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code}>
- {place.code} {place.description && `(${place.description})`}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled>
- 데이터를 불러오는 중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="sparePartResponse">스페어파트 응답 <span className="text-red-500">*</span></Label>
- <Input
- id="sparePartResponse"
- value={responseData.sparePartResponse}
- onChange={(e) => setResponseData({...responseData, sparePartResponse: e.target.value})}
- placeholder={biddingConditions?.sparePartOptions ? `참고: ${biddingConditions.sparePartOptions}` : "스페어파트 관련 응답을 입력하세요"}
- />
- </div>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="additionalProposals">변경사유</Label>
- <Textarea
- id="additionalProposals"
- value={responseData.additionalProposals}
- onChange={(e) => setResponseData({...responseData, additionalProposals: e.target.value})}
- placeholder="변경사유를 입력하세요"
- rows={4}
- />
- </div>
-
- <div className="space-y-4">
- <div className="flex items-center space-x-2">
- <Checkbox
- id="isInitialResponse"
- checked={responseData.isInitialResponse}
- onCheckedChange={(checked) =>
- setResponseData({...responseData, isInitialResponse: !!checked})
- }
- />
- <Label htmlFor="isInitialResponse">초도 공급입니다</Label>
- </div>
-
- <div className="flex items-center space-x-2">
- <Checkbox
- id="priceAdjustmentResponse"
- checked={responseData.priceAdjustmentResponse}
- onCheckedChange={(checked) =>
- setResponseData({...responseData, priceAdjustmentResponse: !!checked})
- }
- />
- <Label htmlFor="priceAdjustmentResponse">연동제 적용에 동의합니다</Label>
- </div>
- </div>
-
- {/* 연동제 상세 정보 (연동제 적용 시에만 표시) */}
- {responseData.priceAdjustmentResponse && (
- <Card className="mt-6">
- <CardHeader>
- <CardTitle className="text-lg">하도급대금등 연동표</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="space-y-2">
- <Label htmlFor="itemName">품목등의 명칭</Label>
- <Input
- id="itemName"
- value={priceAdjustmentForm.itemName}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, itemName: e.target.value})}
- placeholder="품목명을 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentReflectionPoint">조정대금 반영시점</Label>
- <Input
- id="adjustmentReflectionPoint"
- value={priceAdjustmentForm.adjustmentReflectionPoint}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentReflectionPoint: e.target.value})}
- placeholder="반영시점을 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentRatio">연동 비율 (%)</Label>
- <Input
- id="adjustmentRatio"
- type="number"
- step="0.01"
- value={priceAdjustmentForm.adjustmentRatio}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentRatio: e.target.value})}
- placeholder="비율을 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentPeriod">조정주기</Label>
- <Input
- id="adjustmentPeriod"
- value={priceAdjustmentForm.adjustmentPeriod}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentPeriod: e.target.value})}
- placeholder="조정주기를 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="referenceDate">기준시점</Label>
- <Input
- id="referenceDate"
- type="date"
- value={priceAdjustmentForm.referenceDate}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, referenceDate: e.target.value})}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="comparisonDate">비교시점</Label>
- <Input
- id="comparisonDate"
- type="date"
- value={priceAdjustmentForm.comparisonDate}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, comparisonDate: e.target.value})}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="contractorWriter">수탁기업(협력사) 작성자</Label>
- <Input
- id="contractorWriter"
- value={priceAdjustmentForm.contractorWriter}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, contractorWriter: e.target.value})}
- placeholder="작성자명을 입력하세요"
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentDate">조정일</Label>
- <Input
- id="adjustmentDate"
- type="date"
- value={priceAdjustmentForm.adjustmentDate}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentDate: e.target.value})}
- />
- </div>
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="majorApplicableRawMaterial">연동대상 주요 원재료</Label>
- <Textarea
- id="majorApplicableRawMaterial"
- value={priceAdjustmentForm.majorApplicableRawMaterial}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorApplicableRawMaterial: e.target.value})}
- placeholder="연동 대상 원재료를 입력하세요"
- rows={3}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentFormula">하도급대금등 연동 산식</Label>
- <Textarea
- id="adjustmentFormula"
- value={priceAdjustmentForm.adjustmentFormula}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentFormula: e.target.value})}
- placeholder="연동 산식을 입력하세요"
- rows={3}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="rawMaterialPriceIndex">원재료 가격 기준지표</Label>
- <Textarea
- id="rawMaterialPriceIndex"
- value={priceAdjustmentForm.rawMaterialPriceIndex}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, rawMaterialPriceIndex: e.target.value})}
- placeholder="가격 기준지표를 입력하세요"
- rows={2}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="adjustmentConditions">조정요건</Label>
- <Textarea
- id="adjustmentConditions"
- value={priceAdjustmentForm.adjustmentConditions}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, adjustmentConditions: e.target.value})}
- placeholder="조정요건을 입력하세요"
- rows={2}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="majorNonApplicableRawMaterial">연동 미적용 주요 원재료</Label>
- <Textarea
- id="majorNonApplicableRawMaterial"
- value={priceAdjustmentForm.majorNonApplicableRawMaterial}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, majorNonApplicableRawMaterial: e.target.value})}
- placeholder="연동 미적용 원재료를 입력하세요"
- rows={2}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="nonApplicableReason">연동 미적용 사유</Label>
- <Textarea
- id="nonApplicableReason"
- value={priceAdjustmentForm.nonApplicableReason}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, nonApplicableReason: e.target.value})}
- placeholder="미적용 사유를 입력하세요"
- rows={2}
- />
- </div>
-
- <div className="space-y-2">
- <Label htmlFor="priceAdjustmentNotes">기타 사항</Label>
- <Textarea
- id="priceAdjustmentNotes"
- value={priceAdjustmentForm.notes}
- onChange={(e) => setPriceAdjustmentForm({...priceAdjustmentForm, notes: e.target.value})}
- placeholder="기타 사항을 입력하세요"
- rows={2}
- />
- </div>
- </CardContent>
- </Card>
- )}
-
- <div className="flex justify-end gap-2 pt-4">
- <Button
- variant="outline"
- onClick={handleTempSave}
- disabled={isSaving || isPending || (biddingDetail && !['request_for_quotation', 'received_quotation'].includes(biddingDetail.status))}
- >
- <Save className="w-4 h-4 mr-2" />
- {isSaving ? '저장중...' : '임시저장'}
- </Button>
- <Button
- onClick={handleSubmitResponse}
- disabled={isPending || isSaving || (biddingDetail && !['request_for_quotation', 'received_quotation'].includes(biddingDetail.status))}
- >
- <Send className="w-4 h-4 mr-2" />
- 사전견적 제출
- </Button>
- </div>
- </CardContent>
- </Card>
- </>
- )}
- </div>
- )
-}
diff --git a/lib/file-stroage.ts b/lib/file-stroage.ts
index 34b9983a..cb6fdfbd 100644
--- a/lib/file-stroage.ts
+++ b/lib/file-stroage.ts
@@ -28,7 +28,9 @@ const SECURITY_CONFIG = {
'exe', 'bat', 'cmd', 'scr', 'vbs', 'js', 'jar', 'com', 'pif',
'msi', 'reg', 'ps1', 'sh', 'php', 'asp', 'jsp', 'py', 'pl',
// XSS 방지를 위한 추가 확장자
- 'html', 'htm', 'xhtml', 'xml', 'xsl', 'xslt','svg'
+ 'html', 'htm', 'xhtml', 'xml', 'xsl', 'xslt','svg',
+ // 돌체 블랙리스트 추가
+ 'dll', 'vbs', 'js', 'aspx', 'cmd'
]),
// 허용된 MIME 타입
@@ -45,7 +47,7 @@ const SECURITY_CONFIG = {
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed'
]),
- // 최대 파일 크기 (100MB)
+ // 최대 파일 크기 (1GB)
MAX_FILE_SIZE: 1024 * 1024 * 1024,
// 파일명 최대 길이
@@ -129,6 +131,12 @@ class FileSecurityValidator {
// MIME 타입 검증
static validateMimeType(mimeType: string, fileName: string): { valid: boolean; error?: string } {
if (!mimeType) {
+ // xlsx 파일의 경우 MIME 타입이 누락될 수 있으므로 경고만 표시
+ const extension = path.extname(fileName).toLowerCase().substring(1);
+ if (['xlsx', 'xls', 'docx', 'doc', 'pptx', 'ppt', 'pdf', 'dwg', 'dxf', 'zip', 'rar', '7z'].includes(extension)) {
+ console.warn(`⚠️ MIME 타입 누락 (Office 파일 및 주요 확장자): ${fileName}, 확장자 기반으로 허용`);
+ return { valid: true }; // 확장자 기반으로 허용
+ }
return { valid: false, error: "MIME 타입을 확인할 수 없습니다" };
}
diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
index f05fe9ef..25c1fb9a 100644
--- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
+++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
@@ -27,10 +27,6 @@ import { type BasicContractTemplate } from '@/db/schema'
import {
getBasicInfo,
getContractItems,
- getCommunicationChannel,
- getLocation,
- getFieldServiceRate,
- getOffsetDetails,
getSubcontractChecklist,
uploadContractApprovalFile,
sendContractApprovalRequest
@@ -45,10 +41,6 @@ interface ContractApprovalRequestDialogProps {
interface ContractSummary {
basicInfo: Record<string, unknown>
items: Record<string, unknown>[]
- communicationChannel: Record<string, unknown> | null
- location: Record<string, unknown> | null
- fieldServiceRate: Record<string, unknown> | null
- offsetDetails: Record<string, unknown> | null
subcontractChecklist: Record<string, unknown> | null
}
@@ -280,10 +272,6 @@ export function ContractApprovalRequestDialog({
const summary: ContractSummary = {
basicInfo: {},
items: [],
- communicationChannel: null,
- location: null,
- fieldServiceRate: null,
- offsetDetails: null,
subcontractChecklist: null
}
@@ -293,6 +281,14 @@ export function ContractApprovalRequestDialog({
if (basicInfoData && basicInfoData.success) {
summary.basicInfo = basicInfoData.data || {}
}
+ // externalYardEntry 정보도 추가로 가져오기
+ const contractData = await getContractById(contractId)
+ if (contractData) {
+ summary.basicInfo = {
+ ...summary.basicInfo,
+ externalYardEntry: contractData.externalYardEntry || 'N'
+ }
+ }
} catch {
console.log('Basic Info 데이터 없음')
}
@@ -307,47 +303,6 @@ export function ContractApprovalRequestDialog({
console.log('품목 정보 데이터 없음')
}
- // 각 컴포넌트의 활성화 상태 및 데이터 확인
- try {
- // Communication Channel 확인
- const commData = await getCommunicationChannel(contractId)
- if (commData && commData.enabled) {
- summary.communicationChannel = commData
- }
- } catch {
- console.log('Communication Channel 데이터 없음')
- }
-
- try {
- // Location 확인
- const locationData = await getLocation(contractId)
- if (locationData && locationData.enabled) {
- summary.location = locationData
- }
- } catch {
- console.log('Location 데이터 없음')
- }
-
- try {
- // Field Service Rate 확인
- const fieldServiceData = await getFieldServiceRate(contractId)
- if (fieldServiceData && fieldServiceData.enabled) {
- summary.fieldServiceRate = fieldServiceData
- }
- } catch {
- console.log('Field Service Rate 데이터 없음')
- }
-
- try {
- // Offset Details 확인
- const offsetData = await getOffsetDetails(contractId)
- if (offsetData && offsetData.enabled) {
- summary.offsetDetails = offsetData
- }
- } catch {
- console.log('Offset Details 데이터 없음')
- }
-
try {
// Subcontract Checklist 확인
const subcontractData = await getSubcontractChecklist(contractId)
@@ -943,86 +898,6 @@ export function ContractApprovalRequestDialog({
)}
</div>
- {/* 커뮤니케이션 채널 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="communication-enabled"
- checked={!!contractSummary?.communicationChannel}
- disabled
- />
- <Label htmlFor="communication-enabled" className="font-medium">
- 커뮤니케이션 채널
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.communicationChannel
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
-
- {/* 위치 정보 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="location-enabled"
- checked={!!contractSummary?.location}
- disabled
- />
- <Label htmlFor="location-enabled" className="font-medium">
- 위치 정보
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.location
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
-
- {/* 현장 서비스 요율 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="fieldService-enabled"
- checked={!!contractSummary?.fieldServiceRate}
- disabled
- />
- <Label htmlFor="fieldService-enabled" className="font-medium">
- 현장 서비스 요율
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.fieldServiceRate
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
-
- {/* 오프셋 세부사항 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="offset-enabled"
- checked={!!contractSummary?.offsetDetails}
- disabled
- />
- <Label htmlFor="offset-enabled" className="font-medium">
- 오프셋 세부사항
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.offsetDetails
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
-
{/* 하도급 체크리스트 */}
<div className="border rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx
index d891fe63..4071b2e0 100644
--- a/lib/general-contracts/detail/general-contract-basic-info.tsx
+++ b/lib/general-contracts/detail/general-contract-basic-info.tsx
@@ -16,6 +16,11 @@ import { GeneralContract } from '@/db/schema'
import { ContractDocuments } from './general-contract-documents'
import { getPaymentTermsForSelection, getIncotermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from '@/lib/procurement-select/service'
import { TAX_CONDITIONS, getTaxConditionName } from '@/lib/tax-conditions/types'
+import { GENERAL_CONTRACT_SCOPES } from '@/lib/general-contracts/types'
+import { uploadContractAttachment, getContractAttachments, deleteContractAttachment, getContractAttachmentForDownload } from '../service'
+import { downloadFile } from '@/lib/file-download'
+import { FileText, Upload, Download, Trash2 } from 'lucide-react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
interface ContractBasicInfoProps {
contractId: number
@@ -38,6 +43,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
const [procurementLoading, setProcurementLoading] = useState(false)
const [formData, setFormData] = useState({
+ contractScope: '', // 계약확정범위
specificationType: '',
specificationManualText: '',
unitPriceType: '',
@@ -83,9 +89,16 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
projectNotAwarded: false,
other: false,
},
+ externalYardEntry: 'N' as 'Y' | 'N', // 사외업체 야드투입 (Y/N)
+ contractAmountReason: '', // 합의계약 미확정 사유
})
const [errors] = useState<Record<string, string>>({})
+ const [specificationFiles, setSpecificationFiles] = useState<Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }>>([])
+ const [isLoadingSpecFiles, setIsLoadingSpecFiles] = useState(false)
+ const [showSpecFileDialog, setShowSpecFileDialog] = useState(false)
+ const [unitPriceTypeOther, setUnitPriceTypeOther] = useState<string>('') // 단가 유형 '기타' 수기입력
+ const [showYardEntryConfirmDialog, setShowYardEntryConfirmDialog] = useState(false)
// 계약 데이터 로드
React.useEffect(() => {
@@ -121,7 +134,13 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
setPaymentDeliveryPercent(paymentDeliveryPercentValue)
+ // 합의계약(AD, AW)인 경우 인도조건 기본값 설정
+ const defaultDeliveryTerm = (contractData?.type === 'AD' || contractData?.type === 'AW')
+ ? '본 표준하도급 계약에 따름'
+ : (contractData?.deliveryTerm || '')
+
setFormData({
+ contractScope: contractData?.contractScope || '',
specificationType: contractData?.specificationType || '',
specificationManualText: contractData?.specificationManualText || '',
unitPriceType: contractData?.unitPriceType || '',
@@ -145,10 +164,11 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
liquidatedDamages: Boolean(contractData?.liquidatedDamages),
liquidatedDamagesPercent: contractData?.liquidatedDamagesPercent || '',
deliveryType: contractData?.deliveryType || '',
- deliveryTerm: contractData?.deliveryTerm || '',
+ deliveryTerm: defaultDeliveryTerm,
shippingLocation: contractData?.shippingLocation || '',
dischargeLocation: contractData?.dischargeLocation || '',
contractDeliveryDate: contractData?.contractDeliveryDate || '',
+ paymentDeliveryAdditionalText: (contractData as any)?.paymentDeliveryAdditionalText || '',
contractEstablishmentConditions: contractEstablishmentConditions || {
regularVendorRegistration: false,
projectAward: false,
@@ -167,6 +187,8 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
projectNotAwarded: false,
other: false,
},
+ externalYardEntry: (contractData?.externalYardEntry as 'Y' | 'N') || 'N',
+ contractAmountReason: (contractData as any)?.contractAmountReason || '',
})
} catch (error) {
console.error('Error loading contract:', error)
@@ -179,6 +201,33 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
}
}, [contractId])
+ // 사양 파일 목록 로드
+ React.useEffect(() => {
+ const loadSpecificationFiles = async () => {
+ if (!contractId || formData.specificationType !== '첨부서류 참조') return
+
+ setIsLoadingSpecFiles(true)
+ try {
+ const attachments = await getContractAttachments(contractId)
+ const specFiles = (attachments as Array<{ id: number; fileName: string; filePath: string; documentName: string; uploadedAt: Date }>)
+ .filter(att => att.documentName === '사양 및 공급범위' || att.documentName === 'specification')
+ .map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ filePath: att.filePath,
+ uploadedAt: att.uploadedAt
+ }))
+ setSpecificationFiles(specFiles)
+ } catch (error) {
+ console.error('Error loading specification files:', error)
+ } finally {
+ setIsLoadingSpecFiles(false)
+ }
+ }
+
+ loadSpecificationFiles()
+ }, [contractId, formData.specificationType])
+
// Procurement 데이터 로드 함수들
const loadPaymentTerms = React.useCallback(async () => {
setProcurementLoading(true);
@@ -249,7 +298,16 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
// 필수값 validation 체크
const validationErrors: string[] = []
+ if (!formData.contractScope) validationErrors.push('계약확정범위')
if (!formData.specificationType) validationErrors.push('사양')
+ // 첨부서류 참조 선택 시 사양 파일 필수 체크
+ if (formData.specificationType === '첨부서류 참조' && specificationFiles.length === 0) {
+ validationErrors.push('사양 파일')
+ }
+ // LO 계약인 경우 계약체결유효기간 필수값 체크
+ if (contract?.type === 'LO' && !contract?.validityEndDate) {
+ validationErrors.push('계약체결유효기간')
+ }
if (!formData.paymentDelivery) validationErrors.push('납품 지급조건')
if (!formData.currency) validationErrors.push('계약통화')
if (!formData.paymentTerm) validationErrors.push('지불조건')
@@ -294,6 +352,35 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
{/* 기본 정보 탭 */}
<TabsContent value="basic" className="space-y-6">
+ {/* 계약확정범위 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>계약확정범위</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid gap-4">
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="contractScope">계약확정범위 *</Label>
+ <Select
+ value={formData.contractScope}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, contractScope: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="계약확정범위 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {GENERAL_CONTRACT_SCOPES.map((scope) => (
+ <SelectItem key={scope} value={scope}>
+ {scope}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
<Card>
{/* 보증기간 및 단가유형 */}
<CardHeader>
@@ -509,7 +596,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<SelectValue placeholder="사양을 선택하세요" />
</SelectTrigger>
<SelectContent>
- <SelectItem value="첨부파일">첨부파일</SelectItem>
+ <SelectItem value="첨부서류 참조">첨부서류 참조</SelectItem>
<SelectItem value="표준사양">표준사양</SelectItem>
<SelectItem value="수기사양">수기사양</SelectItem>
</SelectContent>
@@ -520,9 +607,26 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
</div>
{/* 단가 */}
<div className="flex flex-col gap-2">
- <Label htmlFor="unitPriceType">단가 유형</Label>
+ <Label htmlFor="unitPriceType">
+ 단가 유형
+ {(() => {
+ const contractType = contract?.type as string || ''
+ const contractCategory = contract?.category as string || ''
+ const unitPriceContractTypes = ['UP', 'LE', 'IL', 'AL', 'OS', 'OW']
+ const isUnitPriceRequired = contractCategory === 'unit_price' && unitPriceContractTypes.includes(contractType)
+ return isUnitPriceRequired ? <span className="text-red-600 ml-1">*</span> : null
+ })()}
+ </Label>
<Select value={formData.unitPriceType} onValueChange={(value) => setFormData(prev => ({ ...prev, unitPriceType: value }))}>
- <SelectTrigger>
+ <SelectTrigger className={
+ (() => {
+ const contractType = contract?.type as string || ''
+ const contractCategory = contract?.category as string || ''
+ const unitPriceContractTypes = ['UP', 'LE', 'IL', 'AL', 'OS', 'OW']
+ const isUnitPriceRequired = contractCategory === 'unit_price' && unitPriceContractTypes.includes(contractType)
+ return isUnitPriceRequired && !formData.unitPriceType ? 'border-red-500' : ''
+ })()
+ }>
<SelectValue placeholder="단가 유형을 선택하세요" />
</SelectTrigger>
<SelectContent>
@@ -535,6 +639,29 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<SelectItem value="기타">기타</SelectItem>
</SelectContent>
</Select>
+ {/* 단가 유형 '기타' 선택 시 수기입력 필드 */}
+ {formData.unitPriceType === '기타' && (
+ <div className="mt-2">
+ <Input
+ value={unitPriceTypeOther}
+ onChange={(e) => setUnitPriceTypeOther(e.target.value)}
+ placeholder="단가 유형을 수기로 입력하세요"
+ className="mt-2"
+ required
+ />
+ </div>
+ )}
+ {(() => {
+ const contractType = contract?.type as string || ''
+ const contractCategory = contract?.category as string || ''
+ const unitPriceContractTypes = ['UP', 'LE', 'IL', 'AL', 'OS', 'OW']
+ const isUnitPriceRequired = contractCategory === 'unit_price' && unitPriceContractTypes.includes(contractType)
+ return isUnitPriceRequired && !formData.unitPriceType ? (
+ <p className="text-sm text-red-600">단가 유형은 필수값입니다.</p>
+ ) : formData.unitPriceType === '기타' && !unitPriceTypeOther.trim() ? (
+ <p className="text-sm text-red-600">단가 유형(기타)을 입력해주세요.</p>
+ ) : null
+ })()}
</div>
{/* 선택에 따른 폼: vertical로 출력 */}
@@ -552,6 +679,187 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
</div>
)}
+ {/* 사양이 첨부서류 참조일 때 파일 업로드 */}
+ {formData.specificationType === '첨부서류 참조' && (
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center justify-between">
+ <Label htmlFor="specificationFile">
+ 사양 파일 <span className="text-red-600">*</span>
+ </Label>
+ <Dialog open={showSpecFileDialog} onOpenChange={setShowSpecFileDialog}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" type="button">
+ <Upload className="h-4 w-4 mr-2" />
+ 파일 업로드
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>사양 파일 업로드</DialogTitle>
+ </DialogHeader>
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="file-upload">파일 선택</Label>
+ <Input
+ id="file-upload"
+ type="file"
+ onChange={async (e) => {
+ const file = e.target.files?.[0]
+ if (!file || !userId) return
+
+ try {
+ setIsLoadingSpecFiles(true)
+ const result = await uploadContractAttachment(
+ contractId,
+ file,
+ userId.toString(),
+ '사양 및 공급범위'
+ )
+
+ if (result.success) {
+ toast.success('사양 파일이 업로드되었습니다.')
+ // 파일 목록 새로고침
+ const attachments = await getContractAttachments(contractId)
+ const specFiles = (attachments as Array<{ id: number; fileName: string; filePath: string; documentName: string; uploadedAt: Date }>)
+ .filter(att => att.documentName === '사양 및 공급범위' || att.documentName === 'specification')
+ .map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ filePath: att.filePath,
+ uploadedAt: att.uploadedAt
+ }))
+ setSpecificationFiles(specFiles)
+ setShowSpecFileDialog(false)
+ e.target.value = ''
+ } else {
+ toast.error(result.error || '파일 업로드에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Error uploading file:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoadingSpecFiles(false)
+ }
+ }}
+ disabled={isLoadingSpecFiles}
+ />
+ </div>
+
+ {/* 업로드된 파일 목록 */}
+ {specificationFiles.length > 0 && (
+ <div className="space-y-2">
+ <Label>업로드된 파일</Label>
+ <div className="space-y-2 max-h-60 overflow-y-auto">
+ {specificationFiles.map((file) => (
+ <div key={file.id} className="flex items-center justify-between p-2 border rounded">
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">{file.fileName}</span>
+ <span className="text-xs text-muted-foreground">
+ ({new Date(file.uploadedAt).toLocaleDateString()})
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={async () => {
+ try {
+ const fileData = await getContractAttachmentForDownload(file.id, contractId)
+ downloadFile(fileData.attachment?.filePath || '', fileData.attachment?.fileName || '', {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Error downloading file:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={async () => {
+ try {
+ await deleteContractAttachment(file.id, contractId)
+ toast.success('파일이 삭제되었습니다.')
+ setSpecificationFiles(prev => prev.filter(f => f.id !== file.id))
+ } catch (error) {
+ console.error('Error deleting file:', error)
+ toast.error('파일 삭제 중 오류가 발생했습니다.')
+ }
+ }}
+ className="text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ {specificationFiles.length === 0 && (
+ <p className="text-sm text-red-600">사양 파일을 업로드해주세요.</p>
+ )}
+
+ {specificationFiles.length > 0 && (
+ <div className="space-y-2">
+ {specificationFiles.map((file) => (
+ <div key={file.id} className="flex items-center justify-between p-2 border rounded text-sm">
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span>{file.fileName}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={async () => {
+ try {
+ const fileData = await getContractAttachmentForDownload(file.id, contractId)
+ downloadFile(fileData.attachment?.filePath || '', fileData.attachment?.fileName || '', {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Error downloading file:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={async () => {
+ try {
+ await deleteContractAttachment(file.id, contractId)
+ toast.success('파일이 삭제되었습니다.')
+ setSpecificationFiles(prev => prev.filter(f => f.id !== file.id))
+ } catch (error) {
+ console.error('Error deleting file:', error)
+ toast.error('파일 삭제 중 오류가 발생했습니다.')
+ }
+ }}
+ className="text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+
</div>
@@ -664,6 +972,37 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
disabled={!formData.paymentBeforeDelivery.materialPurchase}
/>
</div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="additionalConditionBefore"
+ checked={formData.paymentBeforeDelivery.additionalCondition || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ additionalCondition: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="additionalConditionBefore" className="text-sm">추가조건</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentBeforeDelivery.additionalConditionPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ additionalConditionPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentBeforeDelivery.additionalCondition}
+ />
+ </div>
</div>
</div>
@@ -678,26 +1017,26 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<SelectValue placeholder="납품 지급조건을 선택하세요" />
</SelectTrigger>
<SelectContent>
- <SelectItem value="L/C">L/C</SelectItem>
- <SelectItem value="T/T">T/T</SelectItem>
- <SelectItem value="거래명세서 기반 정기지급조건">거래명세서 기반 정기지급조건</SelectItem>
- <SelectItem value="작업 및 입고 검사 완료">작업 및 입고 검사 완료</SelectItem>
- <SelectItem value="청구내역서 제출 및 승인">청구내역서 제출 및 승인</SelectItem>
- <SelectItem value="정규금액 월 단위 정산(지정일 지급)">정규금액 월 단위 정산(지정일 지급)</SelectItem>
+ {/* Payment term 검색 옵션들 */}
+ {paymentTermsOptions.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.code} - {term.description}
+ </SelectItem>
+ ))}
+ <SelectItem value="납품완료일로부터 60일 이내 지급">납품완료일로부터 60일 이내 지급</SelectItem>
+ <SelectItem value="추가조건">추가조건</SelectItem>
</SelectContent>
</Select>
- {/* L/C 또는 T/T 선택 시 퍼센트 입력 필드 */}
- {(formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && (
- <div className="flex items-center gap-2 mt-2">
+ {/* 추가조건 선택 시 수기 입력 필드 */}
+ {formData.paymentDelivery === '추가조건' && (
+ <div className="mt-2">
<Input
- type="number"
- min="0"
- value={paymentDeliveryPercent}
- onChange={(e) => setPaymentDeliveryPercent(e.target.value)}
- placeholder="퍼센트"
- className="w-20 h-8 text-sm"
+ type="text"
+ value={formData.paymentDeliveryAdditionalText || ''}
+ onChange={(e) => setFormData(prev => ({ ...prev, paymentDeliveryAdditionalText: e.target.value }))}
+ placeholder="추가조건을 입력하세요"
+ className="w-full"
/>
- <span className="text-sm text-gray-600">%</span>
</div>
)}
{errors.paymentDelivery && (
@@ -1036,13 +1375,34 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="contractAmount">계약금액 (자동계산)</Label>
- <Input
- type="text"
- value={contract?.contractAmount ? new Intl.NumberFormat('ko-KR').format(Number(contract.contractAmount)) : '품목정보 없음'}
- readOnly
- className="bg-gray-50"
- placeholder="품목정보에서 자동 계산됩니다"
- />
+ {contract?.type === 'AD' || contract?.type === 'AW' ? (
+ <div className="space-y-2">
+ <Input
+ type="text"
+ value="미확정"
+ readOnly
+ className="bg-gray-50"
+ />
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="contractAmountReason">미확정 사유</Label>
+ <Textarea
+ id="contractAmountReason"
+ value={formData.contractAmountReason}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractAmountReason: e.target.value }))}
+ placeholder="계약금액 미확정 사유를 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+ ) : (
+ <Input
+ type="text"
+ value={contract?.contractAmount ? new Intl.NumberFormat('ko-KR').format(Number(contract.contractAmount)) : '품목정보 없음'}
+ readOnly
+ className="bg-gray-50"
+ placeholder="품목정보에서 자동 계산됩니다"
+ />
+ )}
</div>
<div className="space-y-2">
<Label htmlFor="currency">계약통화 <span className="text-red-600">*</span></Label>
@@ -1058,6 +1418,80 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
)}
</div>
+ {/* 사외업체 야드투입 */}
+ <div className="space-y-4 col-span-2">
+ <Label className="text-base font-medium">사외업체 야드투입</Label>
+ <div className="flex items-center space-x-4">
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="yardEntryYes"
+ name="externalYardEntry"
+ value="Y"
+ checked={formData.externalYardEntry === 'Y'}
+ onChange={(e) => setFormData(prev => ({ ...prev, externalYardEntry: 'Y' as 'Y' | 'N' }))}
+ className="rounded"
+ />
+ <Label htmlFor="yardEntryYes">Y</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="yardEntryNo"
+ name="externalYardEntry"
+ value="N"
+ checked={formData.externalYardEntry === 'N'}
+ onChange={(e) => {
+ // 이전 값이 'Y'였고 'N'으로 변경하는 경우 팝업 표시
+ if (formData.externalYardEntry === 'Y') {
+ setShowYardEntryConfirmDialog(true)
+ } else {
+ setFormData(prev => ({ ...prev, externalYardEntry: 'N' as 'Y' | 'N' }))
+ }
+ }}
+ className="rounded"
+ />
+ <Label htmlFor="yardEntryNo">N</Label>
+ </div>
+ </div>
+ {/* 사외업체 야드투입 'N' 선택 시 확인 팝업 */}
+ <Dialog open={showYardEntryConfirmDialog} onOpenChange={setShowYardEntryConfirmDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>안전필수사항 확인</DialogTitle>
+ </DialogHeader>
+ <div className="space-y-4">
+ <p className="text-sm text-muted-foreground">
+ 안전필수사항으로 사내작업여부를 재확인 바랍니다.
+ </p>
+ <div className="flex justify-end gap-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setShowYardEntryConfirmDialog(false)
+ // 라디오 버튼을 다시 'Y'로 되돌림
+ const yardEntryYesRadio = document.getElementById('yardEntryYes') as HTMLInputElement
+ if (yardEntryYesRadio) {
+ yardEntryYesRadio.checked = true
+ }
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={() => {
+ setFormData(prev => ({ ...prev, externalYardEntry: 'N' as 'Y' | 'N' }))
+ setShowYardEntryConfirmDialog(false)
+ }}
+ >
+ 확인
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </div>
+
{/* 계약성립조건 */}
<div className="space-y-4 col-span-2">
<Label className="text-base font-medium">계약성립조건</Label>
diff --git a/lib/general-contracts/detail/general-contract-detail.tsx b/lib/general-contracts/detail/general-contract-detail.tsx
index 8e7a7aff..f2a916f8 100644
--- a/lib/general-contracts/detail/general-contract-detail.tsx
+++ b/lib/general-contracts/detail/general-contract-detail.tsx
@@ -12,11 +12,11 @@ import { Skeleton } from '@/components/ui/skeleton'
import { ContractItemsTable } from './general-contract-items-table'
import { SubcontractChecklist } from './general-contract-subcontract-checklist'
import { ContractBasicInfo } from './general-contract-basic-info'
-import { CommunicationChannel } from './general-contract-communication-channel'
-import { Location } from './general-contract-location'
-import { FieldServiceRate } from './general-contract-field-service-rate'
-import { OffsetDetails } from './general-contract-offset-details'
import { ContractApprovalRequestDialog } from './general-contract-approval-request-dialog'
+import { ContractStorageInfo } from './general-contract-storage-info'
+import { ContractYardEntryInfo } from './general-contract-yard-entry-info'
+import { ContractReviewComments } from './general-contract-review-comments'
+import { ContractReviewRequestDialog } from './general-contract-review-request-dialog'
export default function ContractDetailPage() {
const params = useParams()
@@ -26,7 +26,8 @@ export default function ContractDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showApprovalDialog, setShowApprovalDialog] = useState(false)
- const [subcontractChecklistData, setSubcontractChecklistData] = useState<any>(null)
+ const [subcontractChecklistData, setSubcontractChecklistData] = useState<Record<string, unknown> | null>(null)
+ const [showReviewDialog, setShowReviewDialog] = useState(false)
useEffect(() => {
const fetchContract = async () => {
@@ -110,13 +111,24 @@ export default function ContractDetailPage() {
</p>
</div>
<div className="flex gap-2">
+ {/* 조건검토요청 버튼 - Draft 상태일 때만 표시 */}
+ {contract?.status === 'Draft' && (
+ <Button
+ onClick={() => setShowReviewDialog(true)}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ 조건검토요청
+ </Button>
+ )}
{/* 계약승인요청 버튼 */}
- <Button
- onClick={() => setShowApprovalDialog(true)}
- className="bg-blue-600 hover:bg-blue-700"
- >
- 계약승인요청
- </Button>
+ <>
+ <Button
+ onClick={() => setShowApprovalDialog(true)}
+ className="bg-blue-600 hover:bg-blue-700"
+ >
+ 계약승인요청
+ </Button>
+ </>
{/* 계약목록으로 돌아가기 버튼 */}
<Button asChild variant="outline" size="sm">
<Link href="/evcp/general-contracts">
@@ -150,7 +162,8 @@ export default function ContractDetailPage() {
onItemsChange={() => {}}
onTotalAmountChange={() => {}}
availableBudget={0}
- readOnly={contract?.contractScope === '단가' || contract?.contractScope === '물량(실적)'}
+ readOnly={false}
+ contractScope={contract?.contractScope as string || ''}
/>
{/* 하도급법 자율점검 체크리스트 */}
<SubcontractChecklist
@@ -158,18 +171,31 @@ export default function ContractDetailPage() {
onDataChange={(data) => setSubcontractChecklistData(data)}
readOnly={false}
initialData={subcontractChecklistData}
+ contractType={contract?.type as string || ''}
+ vendorCountry={(contract as any)?.vendorCountry || 'KR'}
+ />
+
+ {/* 임치(물품보관)계약 상세 정보 - SG 계약종류일 때만 표시 */}
+ {contract?.type === 'SG' && (
+ <ContractStorageInfo
+ contractId={contract.id as number}
+ readOnly={false}
+ />
+ )}
+
+ {/* 사외업체 야드투입 정보 - externalYardEntry가 'Y'일 때만 표시 */}
+ {contract?.externalYardEntry === 'Y' && (
+ <ContractYardEntryInfo
+ contractId={contract.id as number}
+ readOnly={false}
+ />
+ )}
+
+ {/* 계약 조건 검토 의견 섹션 */}
+ <ContractReviewComments
+ contractId={contract.id as number}
+ contractStatus={contract.status as string}
/>
- {/* Communication Channel */}
- <CommunicationChannel contractId={Number(contract.id)} />
-
- {/* Location */}
- <Location contractId={Number(contract.id)} />
-
- {/* Field Service Rate */}
- <FieldServiceRate contractId={Number(contract.id)} />
-
- {/* Offset Details */}
- <OffsetDetails contractId={Number(contract.id)} />
</div>
)}
@@ -181,6 +207,15 @@ export default function ContractDetailPage() {
onOpenChange={setShowApprovalDialog}
/>
)}
+
+ {/* 조건검토요청 다이얼로그 */}
+ {contract && (
+ <ContractReviewRequestDialog
+ contract={contract}
+ open={showReviewDialog}
+ onOpenChange={setShowReviewDialog}
+ />
+ )}
</div>
)
}
diff --git a/lib/general-contracts/detail/general-contract-documents.tsx b/lib/general-contracts/detail/general-contract-documents.tsx
index b0f20e7f..ee2af8a2 100644
--- a/lib/general-contracts/detail/general-contract-documents.tsx
+++ b/lib/general-contracts/detail/general-contract-documents.tsx
@@ -22,7 +22,8 @@ import {
uploadContractAttachment,
getContractAttachments,
getContractAttachmentForDownload,
- deleteContractAttachment
+ deleteContractAttachment,
+ saveContractAttachmentComment
} from '../service'
import { downloadFile } from '@/lib/file-download'
@@ -138,7 +139,13 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont
if (!editingComment) return
try {
- // TODO: API 호출로 댓글 저장
+ await saveContractAttachmentComment(
+ editingComment.id,
+ contractId,
+ editingComment.type,
+ commentText,
+ Number(userId)
+ )
toast.success('댓글이 저장되었습니다.')
setEditingComment(null)
setCommentText('')
diff --git a/lib/general-contracts/detail/general-contract-info-header.tsx b/lib/general-contracts/detail/general-contract-info-header.tsx
index 9be9840d..675918a2 100644
--- a/lib/general-contracts/detail/general-contract-info-header.tsx
+++ b/lib/general-contracts/detail/general-contract-info-header.tsx
@@ -52,15 +52,14 @@ const typeLabels = {
'AL': '연간운송계약',
'OS': '외주용역계약',
'OW': '도급계약',
- 'IS': '검사계약',
'LO': 'LOI',
'FA': 'FA',
'SC': '납품합의계약',
'OF': '클레임상계계약',
'AW': '사전작업합의',
'AD': '사전납품합의',
- 'AM': '설계계약',
- 'SC_SELL': '폐기물매각계약'
+ 'SG': '임치(물품보관)계약',
+ 'SR': '폐기물매각계약'
}
export function GeneralContractInfoHeader({ contract }: GeneralContractInfoHeaderProps) {
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx
index 1b9a1a06..ed1e5afb 100644
--- a/lib/general-contracts/detail/general-contract-items-table.tsx
+++ b/lib/general-contracts/detail/general-contract-items-table.tsx
@@ -20,15 +20,26 @@ import {
Package,
Plus,
Trash2,
+ FileSpreadsheet,
+ Save,
+ LoaderIcon
} from 'lucide-react'
import { toast } from 'sonner'
import { updateContractItems, getContractItems } from '../service'
-import { Save, LoaderIcon } from 'lucide-react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { ProjectSelector } from '@/components/ProjectSelector'
+import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
+import { MaterialSearchItem } from '@/lib/material/material-group-service'
interface ContractItem {
id?: number
+ projectId?: number | null
+ projectName?: string
+ projectCode?: string
itemCode: string
itemInfo: string
+ materialGroupCode?: string
+ materialGroupDescription?: string
specification: string
quantity: number
quantityUnit: string
@@ -49,6 +60,9 @@ interface ContractItemsTableProps {
onTotalAmountChange: (total: number) => void
availableBudget?: number
readOnly?: boolean
+ contractScope?: string // 계약확정범위 (단가/금액/물량)
+ deliveryType?: string // 납기종류 (단일납기/분할납기)
+ contractDeliveryDate?: string // 기본정보의 계약납기일
}
// 통화 목록
@@ -66,12 +80,28 @@ export function ContractItemsTable({
onItemsChange,
onTotalAmountChange,
availableBudget = 0,
- readOnly = false
+ readOnly = false,
+ contractScope = '',
+ deliveryType = '',
+ contractDeliveryDate = ''
}: ContractItemsTableProps) {
+ // 계약확정범위에 따른 필드 활성화/비활성화
+ const isQuantityDisabled = contractScope === '단가' || contractScope === '물량'
+ const isTotalAmountDisabled = contractScope === '단가' || contractScope === '물량'
+ // 단일납기인 경우 납기일 필드 비활성화 및 기본값 설정
+ const isDeliveryDateDisabled = deliveryType === '단일납기'
const [localItems, setLocalItems] = React.useState<ContractItem[]>(items)
const [isSaving, setIsSaving] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(false)
const [isEnabled, setIsEnabled] = React.useState(true)
+ const [showBatchInputDialog, setShowBatchInputDialog] = React.useState(false)
+ const [batchInputData, setBatchInputData] = React.useState({
+ quantity: '',
+ quantityUnit: 'EA',
+ contractDeliveryDate: '',
+ contractCurrency: 'KRW',
+ contractUnitPrice: ''
+ })
// 초기 데이터 로드
React.useEffect(() => {
@@ -79,21 +109,41 @@ export function ContractItemsTable({
try {
setIsLoading(true)
const fetchedItems = await getContractItems(contractId)
- const formattedItems = fetchedItems.map(item => ({
- id: item.id,
- itemCode: item.itemCode || '',
- itemInfo: item.itemInfo || '',
- specification: item.specification || '',
- quantity: Number(item.quantity) || 0,
- quantityUnit: item.quantityUnit || 'EA',
- totalWeight: Number(item.totalWeight) || 0,
- weightUnit: item.weightUnit || 'KG',
- contractDeliveryDate: item.contractDeliveryDate || '',
- contractUnitPrice: Number(item.contractUnitPrice) || 0,
- contractAmount: Number(item.contractAmount) || 0,
- contractCurrency: item.contractCurrency || 'KRW',
- isSelected: false
- })) as ContractItem[]
+ const formattedItems = fetchedItems.map(item => {
+ // itemInfo에서 자재그룹 정보 파싱 (형식: "자재그룹코드 / 자재그룹명")
+ let materialGroupCode = ''
+ let materialGroupDescription = ''
+ if (item.itemInfo) {
+ const parts = item.itemInfo.split(' / ')
+ if (parts.length >= 2) {
+ materialGroupCode = parts[0].trim()
+ materialGroupDescription = parts.slice(1).join(' / ').trim()
+ } else if (parts.length === 1) {
+ materialGroupCode = parts[0].trim()
+ }
+ }
+
+ return {
+ id: item.id,
+ projectId: item.projectId || null,
+ projectName: item.projectName || undefined,
+ projectCode: item.projectCode || undefined,
+ itemCode: item.itemCode || '',
+ itemInfo: item.itemInfo || '',
+ materialGroupCode: materialGroupCode || undefined,
+ materialGroupDescription: materialGroupDescription || undefined,
+ specification: item.specification || '',
+ quantity: Number(item.quantity) || 0,
+ quantityUnit: item.quantityUnit || 'EA',
+ totalWeight: Number(item.totalWeight) || 0,
+ weightUnit: item.weightUnit || 'KG',
+ contractDeliveryDate: item.contractDeliveryDate || '',
+ contractUnitPrice: Number(item.contractUnitPrice) || 0,
+ contractAmount: Number(item.contractAmount) || 0,
+ contractCurrency: item.contractCurrency || 'KRW',
+ isSelected: false
+ }
+ }) as ContractItem[]
setLocalItems(formattedItems as ContractItem[])
onItemsChange(formattedItems as ContractItem[])
} catch (error) {
@@ -176,8 +226,11 @@ export function ContractItemsTable({
// 행 추가
const addRow = () => {
const newItem: ContractItem = {
+ projectId: null,
itemCode: '',
itemInfo: '',
+ materialGroupCode: '',
+ materialGroupDescription: '',
specification: '',
quantity: 0,
quantityUnit: 'EA', // 기본 수량 단위
@@ -218,6 +271,43 @@ export function ContractItemsTable({
onItemsChange(updatedItems)
}
+ // 일괄입력 적용
+ const applyBatchInput = () => {
+ if (localItems.length === 0) {
+ toast.error('품목이 없습니다. 먼저 품목을 추가해주세요.')
+ return
+ }
+
+ const updatedItems = localItems.map(item => {
+ const updatedItem = { ...item }
+
+ if (batchInputData.quantity) {
+ updatedItem.quantity = parseFloat(batchInputData.quantity) || 0
+ }
+ if (batchInputData.quantityUnit) {
+ updatedItem.quantityUnit = batchInputData.quantityUnit
+ }
+ if (batchInputData.contractDeliveryDate) {
+ updatedItem.contractDeliveryDate = batchInputData.contractDeliveryDate
+ }
+ if (batchInputData.contractCurrency) {
+ updatedItem.contractCurrency = batchInputData.contractCurrency
+ }
+ if (batchInputData.contractUnitPrice) {
+ updatedItem.contractUnitPrice = parseFloat(batchInputData.contractUnitPrice) || 0
+ // 단가가 변경되면 계약금액도 재계산
+ updatedItem.contractAmount = updatedItem.contractUnitPrice * updatedItem.quantity
+ }
+
+ return updatedItem
+ })
+
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ setShowBatchInputDialog(false)
+ toast.success('일괄입력이 적용되었습니다.')
+ }
+
// 통화 포맷팅
const formatCurrency = (amount: number, currency: string = 'KRW') => {
@@ -292,6 +382,104 @@ export function ContractItemsTable({
<Plus className="w-4 h-4" />
행 추가
</Button>
+ <Dialog open={showBatchInputDialog} onOpenChange={setShowBatchInputDialog}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={!isEnabled || localItems.length === 0}
+ className="flex items-center gap-2"
+ >
+ <FileSpreadsheet className="w-4 h-4" />
+ 일괄입력
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>품목 정보 일괄입력</DialogTitle>
+ </DialogHeader>
+ <div className="space-y-4 py-4">
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-quantity">수량</Label>
+ <Input
+ id="batch-quantity"
+ type="number"
+ value={batchInputData.quantity}
+ onChange={(e) => setBatchInputData(prev => ({ ...prev, quantity: e.target.value }))}
+ placeholder="수량 입력 (선택사항)"
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-quantity-unit">수량단위</Label>
+ <Select
+ value={batchInputData.quantityUnit}
+ onValueChange={(value) => setBatchInputData(prev => ({ ...prev, quantityUnit: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {QUANTITY_UNITS.map((unit) => (
+ <SelectItem key={unit} value={unit}>
+ {unit}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-delivery-date">계약납기일</Label>
+ <Input
+ id="batch-delivery-date"
+ type="date"
+ value={batchInputData.contractDeliveryDate}
+ onChange={(e) => setBatchInputData(prev => ({ ...prev, contractDeliveryDate: e.target.value }))}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-currency">계약통화</Label>
+ <Select
+ value={batchInputData.contractCurrency}
+ onValueChange={(value) => setBatchInputData(prev => ({ ...prev, contractCurrency: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {CURRENCIES.map((currency) => (
+ <SelectItem key={currency} value={currency}>
+ {currency}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-unit-price">계약단가</Label>
+ <Input
+ id="batch-unit-price"
+ type="number"
+ value={batchInputData.contractUnitPrice}
+ onChange={(e) => setBatchInputData(prev => ({ ...prev, contractUnitPrice: e.target.value }))}
+ placeholder="계약단가 입력 (선택사항)"
+ />
+ </div>
+ <div className="flex justify-end gap-2 pt-4">
+ <Button
+ variant="outline"
+ onClick={() => setShowBatchInputDialog(false)}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={applyBatchInput}
+ >
+ 적용
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
<Button
variant="outline"
size="sm"
@@ -322,8 +510,8 @@ export function ContractItemsTable({
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="space-y-1">
<Label className="text-sm font-medium">총 계약금액</Label>
- <div className="text-lg font-bold text-primary">
- {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
+ <div className={`text-lg font-bold ${isTotalAmountDisabled ? 'text-gray-400' : 'text-primary'}`}>
+ {isTotalAmountDisabled ? '-' : formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
</div>
</div>
<div className="space-y-1">
@@ -364,8 +552,10 @@ export function ContractItemsTable({
/>
)}
</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">프로젝트</TableHead>
<TableHead className="px-3 py-3 font-semibold">품목코드 (PKG No.)</TableHead>
- <TableHead className="px-3 py-3 font-semibold">Item 정보 (자재그룹 / 자재코드)</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">자재그룹</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">자재내역(자재그룹명)</TableHead>
<TableHead className="px-3 py-3 font-semibold">규격</TableHead>
<TableHead className="px-3 py-3 font-semibold text-right">수량</TableHead>
<TableHead className="px-3 py-3 font-semibold">수량단위</TableHead>
@@ -393,6 +583,21 @@ export function ContractItemsTable({
</TableCell>
<TableCell className="px-3 py-3">
{readOnly ? (
+ <span className="text-sm">{item.projectCode && item.projectName ? `${item.projectCode} - ${item.projectName}` : '-'}</span>
+ ) : (
+ <ProjectSelector
+ selectedProjectId={item.projectId || undefined}
+ onProjectSelect={(project) => {
+ updateItem(index, 'projectId', project.id)
+ updateItem(index, 'projectName', project.projectName)
+ updateItem(index, 'projectCode', project.projectCode)
+ }}
+ placeholder="프로젝트 선택"
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
<span className="text-sm">{item.itemCode || '-'}</span>
) : (
<Input
@@ -406,13 +611,42 @@ export function ContractItemsTable({
</TableCell>
<TableCell className="px-3 py-3">
{readOnly ? (
- <span className="text-sm">{item.itemInfo || '-'}</span>
+ <span className="text-sm">{item.materialGroupCode || '-'}</span>
+ ) : (
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupCode || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupCode ? {
+ materialGroupCode: item.materialGroupCode,
+ materialGroupDescription: item.materialGroupDescription || '',
+ displayText: `${item.materialGroupCode} - ${item.materialGroupDescription || ''}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updateItem(index, 'materialGroupCode', material.materialGroupCode)
+ updateItem(index, 'materialGroupDescription', material.materialGroupDescription)
+ updateItem(index, 'itemInfo', `${material.materialGroupCode} / ${material.materialGroupDescription}`)
+ } else {
+ updateItem(index, 'materialGroupCode', '')
+ updateItem(index, 'materialGroupDescription', '')
+ updateItem(index, 'itemInfo', '')
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.materialGroupDescription || item.itemInfo || '-'}</span>
) : (
<Input
- value={item.itemInfo}
- onChange={(e) => updateItem(index, 'itemInfo', e.target.value)}
- placeholder="Item 정보"
- className="h-8 text-sm"
+ value={item.materialGroupDescription || item.itemInfo || ''}
+ onChange={(e) => updateItem(index, 'materialGroupDescription', e.target.value)}
+ placeholder="자재그룹명"
+ className="h-8 text-sm bg-muted/50"
+ readOnly
disabled={!isEnabled}
/>
)}
@@ -440,7 +674,7 @@ export function ContractItemsTable({
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
className="h-8 text-sm text-right"
placeholder="0"
- disabled={!isEnabled}
+ disabled={!isEnabled || isQuantityDisabled}
/>
)}
</TableCell>
@@ -451,7 +685,7 @@ export function ContractItemsTable({
<Select
value={item.quantityUnit}
onValueChange={(value) => updateItem(index, 'quantityUnit', value)}
- disabled={!isEnabled}
+ disabled={!isEnabled || isQuantityDisabled}
>
<SelectTrigger className="h-8 text-sm w-20">
<SelectValue />
@@ -476,7 +710,7 @@ export function ContractItemsTable({
onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)}
className="h-8 text-sm text-right"
placeholder="0"
- disabled={!isEnabled}
+ disabled={!isEnabled || isQuantityDisabled}
/>
)}
</TableCell>
@@ -511,7 +745,7 @@ export function ContractItemsTable({
value={item.contractDeliveryDate}
onChange={(e) => updateItem(index, 'contractDeliveryDate', e.target.value)}
className="h-8 text-sm"
- disabled={!isEnabled}
+ disabled={!isEnabled || isDeliveryDateDisabled}
/>
)}
</TableCell>
diff --git a/lib/general-contracts/detail/general-contract-review-comments.tsx b/lib/general-contracts/detail/general-contract-review-comments.tsx
new file mode 100644
index 00000000..e80211f2
--- /dev/null
+++ b/lib/general-contracts/detail/general-contract-review-comments.tsx
@@ -0,0 +1,194 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Button } from '@/components/ui/button'
+import { MessageSquare, Send, Save } from 'lucide-react'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+import {
+ getContractReviewComments,
+ confirmContractReview
+} from '../service'
+
+interface ContractReviewCommentsProps {
+ contractId: number
+ contractStatus: string
+}
+
+export function ContractReviewComments({ contractId, contractStatus }: ContractReviewCommentsProps) {
+ const session = useSession()
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+
+ const [vendorComment, setVendorComment] = useState<string>('')
+ const [shiComment, setShiComment] = useState<string>('')
+ const [isSaving, setIsSaving] = useState(false)
+ const [isEditingShiComment, setIsEditingShiComment] = useState(false)
+
+ // 계약 상태에 따른 표시 여부
+ const showVendorComment = ['Request to Review', 'Vendor Replied Review', 'SHI Confirmed Review'].includes(contractStatus)
+ const showShiComment = ['Vendor Replied Review', 'SHI Confirmed Review'].includes(contractStatus)
+ const canEditShiComment = contractStatus === 'Vendor Replied Review' && userId
+
+ useEffect(() => {
+ const loadComments = async () => {
+ try {
+ const result = await getContractReviewComments(contractId)
+ if (result.success) {
+ if (result.vendorComment) {
+ setVendorComment(result.vendorComment)
+ }
+ if (result.shiComment) {
+ setShiComment(result.shiComment)
+ setIsEditingShiComment(false) // 이미 저장된 의견이 있으면 편집 모드 해제
+ } else {
+ setIsEditingShiComment(canEditShiComment ? true : false) // 의견이 없고 편집 가능하면 편집 모드
+ }
+ }
+ } catch (error) {
+ console.error('의견 로드 오류:', error)
+ }
+ }
+
+ if (showVendorComment || showShiComment) {
+ loadComments()
+ }
+ }, [contractId, showVendorComment, showShiComment, canEditShiComment])
+
+ const handleConfirmReview = async () => {
+ if (!shiComment.trim()) {
+ toast.error('SHI 의견을 입력해주세요.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ await confirmContractReview(contractId, shiComment, userId)
+ toast.success('검토가 확정되었습니다.')
+ // 페이지 새로고침
+ window.location.reload()
+ } catch (error) {
+ console.error('검토 확정 오류:', error)
+ const errorMessage = error instanceof Error ? error.message : '검토 확정에 실패했습니다.'
+ toast.error(errorMessage)
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 계약 조건 검토 의견
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* Vendor Comment */}
+ {showVendorComment && (
+ <div className="space-y-2">
+ <Label className="text-sm font-medium">Vendor Comment</Label>
+ <div className="min-h-[120px] p-4 bg-yellow-50 border-2 border-yellow-200 rounded-lg">
+ {vendorComment ? (
+ <p className="text-sm whitespace-pre-wrap">{vendorComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">협력업체 의견이 없습니다.</p>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* SHI Comment */}
+ {showShiComment && (
+ <div className="space-y-2">
+ <Label className="text-sm font-medium">SHI Comment</Label>
+ {isEditingShiComment ? (
+ <div className="space-y-2">
+ <Textarea
+ value={shiComment}
+ onChange={(e) => setShiComment(e.target.value)}
+ placeholder="SHI 의견을 입력하세요"
+ rows={6}
+ className="resize-none"
+ disabled={isSaving}
+ />
+ <div className="flex gap-2">
+ <Button
+ onClick={handleConfirmReview}
+ disabled={isSaving || !shiComment.trim()}
+ className="flex-1"
+ >
+ {isSaving ? (
+ <>
+ <Save className="h-4 w-4 mr-2 animate-spin" />
+ 확정 중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ 의견 회신 및 검토 확정
+ </>
+ )}
+ </Button>
+ {shiComment && (
+ <Button
+ variant="outline"
+ onClick={() => {
+ setIsEditingShiComment(false)
+ // 원래 값으로 복원하기 위해 다시 로드
+ getContractReviewComments(contractId).then((result) => {
+ if (result.success) {
+ setShiComment(result.shiComment || '')
+ }
+ })
+ }}
+ disabled={isSaving}
+ >
+ 취소
+ </Button>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ <div className="min-h-[120px] p-4 bg-gray-50 border rounded-lg">
+ {shiComment ? (
+ <p className="text-sm whitespace-pre-wrap">{shiComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">SHI 의견이 없습니다.</p>
+ )}
+ </div>
+ {canEditShiComment && (
+ <Button
+ variant="outline"
+ onClick={() => setIsEditingShiComment(true)}
+ className="w-full"
+ >
+ <Save className="h-4 w-4 mr-2" />
+ 의견 수정
+ </Button>
+ )}
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 상태가 아닌 경우 안내 메시지 */}
+ {!showVendorComment && !showShiComment && (
+ <div className="text-center py-8 text-muted-foreground">
+ <p>조건검토 요청 상태가 아닙니다.</p>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/lib/general-contracts/detail/general-contract-review-request-dialog.tsx b/lib/general-contracts/detail/general-contract-review-request-dialog.tsx
new file mode 100644
index 00000000..b487ae25
--- /dev/null
+++ b/lib/general-contracts/detail/general-contract-review-request-dialog.tsx
@@ -0,0 +1,891 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { Input } from '@/components/ui/input'
+import { toast } from 'sonner'
+import {
+ FileText,
+ Upload,
+ Eye,
+ Send,
+ CheckCircle,
+ Download,
+} from 'lucide-react'
+import {
+ getBasicInfo,
+ getContractItems,
+ getSubcontractChecklist,
+ uploadContractReviewFile,
+ sendContractReviewRequest,
+ getContractById
+} from '../service'
+
+interface ContractReviewRequestDialogProps {
+ contract: Record<string, unknown>
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>
+ items: Record<string, unknown>[]
+ subcontractChecklist: Record<string, unknown> | null
+}
+
+export function ContractReviewRequestDialog({
+ contract,
+ open,
+ onOpenChange
+}: ContractReviewRequestDialogProps) {
+ const { data: session } = useSession()
+ const [currentStep, setCurrentStep] = useState(1)
+ const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
+ const [uploadedFile, setUploadedFile] = useState<File | null>(null)
+ const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
+ const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [pdfViewerInstance, setPdfViewerInstance] = useState<unknown>(null)
+ const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
+
+ const contractId = contract.id as number
+ const userId = session?.user?.id || ''
+
+ // LOI 템플릿용 변수 매핑 함수
+ const mapContractSummaryToLOITemplate = (contractSummary: ContractSummary) => {
+ const { basicInfo, items } = contractSummary
+ const firstItem = items && items.length > 0 ? items[0] : {}
+
+ // 날짜 포맷팅 헬퍼 함수
+ const formatDate = (date: unknown) => {
+ if (!date) return ''
+ try {
+ const d = new Date(date)
+ return d.toLocaleDateString('ko-KR')
+ } catch {
+ return ''
+ }
+ }
+
+ return {
+ // 날짜 관련 (템플릿에서 {{todayDate}} 형식으로 사용)
+ todayDate: new Date().toLocaleDateString('ko-KR'),
+
+ // 벤더 정보
+ vendorName: basicInfo?.vendorName || '',
+ representativeName: '', // 벤더 대표자 이름 - 현재 데이터에 없음, 향후 확장 가능
+
+ // 계약 기본 정보
+ contractNumber: basicInfo?.contractNumber || '',
+
+ // 프로젝트 정보
+ projectNumber: '', // 프로젝트 코드 - 현재 데이터에 없음, 향후 확장 가능
+ projectName: basicInfo?.projectName || '',
+ project: basicInfo?.projectName || '',
+
+ // 아이템 정보
+ item: firstItem?.itemInfo || '',
+
+ // 무역 조건
+ incoterms: basicInfo?.deliveryTerm || '', // Incoterms 대신 deliveryTerm 사용
+ shippingLocation: basicInfo?.shippingLocation || '',
+
+ // 금액 및 통화
+ contractCurrency: basicInfo?.currency || '',
+ contractAmount: basicInfo?.contractAmount || '',
+ totalAmount: basicInfo?.contractAmount || '', // totalAmount가 없으면 contractAmount 사용
+
+ // 수량
+ quantity: firstItem?.quantity || '',
+
+ // 납기일
+ contractDeliveryDate: formatDate(basicInfo?.contractDeliveryDate),
+
+ // 지급 조건
+ paymentTerm: basicInfo?.paymentTerm || '',
+
+ // 유효기간
+ validityEndDate: formatDate(basicInfo?.endDate), // validityEndDate 대신 endDate 사용
+ }
+ }
+
+ // 1단계: 계약 현황 수집
+ const collectContractSummary = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ // 각 컴포넌트에서 활성화된 데이터만 수집
+ const summary: ContractSummary = {
+ basicInfo: {},
+ items: [],
+ subcontractChecklist: null
+ }
+
+ // Basic Info 확인 (항상 활성화)
+ try {
+ const basicInfoData = await getBasicInfo(contractId)
+ if (basicInfoData && basicInfoData.success) {
+ summary.basicInfo = basicInfoData.data || {}
+ }
+ // externalYardEntry 정보도 추가로 가져오기
+ const contractData = await getContractById(contractId)
+ if (contractData) {
+ summary.basicInfo = {
+ ...summary.basicInfo,
+ externalYardEntry: contractData.externalYardEntry || 'N'
+ }
+ }
+ } catch {
+ console.log('Basic Info 데이터 없음')
+ }
+
+ // 품목 정보 확인
+ try {
+ const itemsData = await getContractItems(contractId)
+ if (itemsData && itemsData.length > 0) {
+ summary.items = itemsData
+ }
+ } catch {
+ console.log('품목 정보 데이터 없음')
+ }
+
+ try {
+ // Subcontract Checklist 확인
+ const subcontractData = await getSubcontractChecklist(contractId)
+ if (subcontractData && subcontractData.success && subcontractData.enabled) {
+ summary.subcontractChecklist = subcontractData.data
+ }
+ } catch {
+ console.log('Subcontract Checklist 데이터 없음')
+ }
+
+ console.log('contractSummary 구조:', summary)
+ console.log('basicInfo 내용:', summary.basicInfo)
+ setContractSummary(summary)
+ } catch (error) {
+ console.error('Error collecting contract summary:', error)
+ toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [contractId])
+
+ // 3단계: 파일 업로드 처리
+ const handleFileUpload = async (file: File) => {
+ // 파일 확장자 검증
+ const allowedExtensions = ['.doc', '.docx']
+ const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
+
+ if (!allowedExtensions.includes(fileExtension)) {
+ toast.error('Word 문서(.doc, .docx) 파일만 업로드 가능합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 서버액션을 사용하여 파일 저장 (조건검토용)
+ const result = await uploadContractReviewFile(
+ contractId,
+ file,
+ userId
+ )
+
+ if (result.success) {
+ setUploadedFile(file)
+ toast.success('파일이 업로드되었습니다.')
+ } else {
+ throw new Error(result.error || '파일 업로드 실패')
+ }
+ } catch (error) {
+ console.error('Error uploading file:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 4단계: PDF 생성 및 미리보기 (PDFTron 사용)
+ const generatePdf = async () => {
+ if (!uploadedFile || !contractSummary) {
+ toast.error('업로드된 파일과 계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // @ts-expect-error - PDFTron WebViewer dynamic import
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
+ const tempDiv = document.createElement('div')
+ tempDiv.style.display = 'none'
+ document.body.appendChild(tempDiv)
+
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ )
+
+ try {
+ const { Core } = instance
+ const { createDocument } = Core
+
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(uploadedFile, {
+ filename: uploadedFile.name,
+ extension: 'docx',
+ })
+
+ // LOI 템플릿용 변수 매핑
+ const mappedTemplateData = mapContractSummaryToLOITemplate(contractSummary)
+
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as Record<string, unknown>)
+ console.log("✅ 변수 치환 완료")
+
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await (Core as { officeToPDFBuffer: (data: unknown, options: { extension: string }) => Promise<Uint8Array> }).officeToPDFBuffer(fileData, { extension: 'docx' })
+
+ console.log(`✅ PDF 변환 완료: ${uploadedFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+
+ // PDF 버퍼를 Blob URL로 변환하여 미리보기
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ setGeneratedPdfUrl(pdfUrl)
+
+ // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
+ setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
+
+ toast.success('PDF가 생성되었습니다.')
+
+ } finally {
+ // 임시 WebViewer 정리
+ instance.UI.dispose()
+ document.body.removeChild(tempDiv)
+ }
+
+ } catch (error) {
+ console.error('❌ PDF 생성 실패:', error)
+ toast.error('PDF 생성 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 미리보기 기능
+ const openPdfPreview = async () => {
+ if (!generatedPdfBuffer) {
+ toast.error('생성된 PDF가 없습니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // @ts-expect-error - PDFTron WebViewer dynamic import
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 기존 인스턴스가 있다면 정리
+ if (pdfViewerInstance) {
+ console.log("🔄 기존 WebViewer 인스턴스 정리")
+ try {
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('기존 WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 미리보기용 컨테이너 확인
+ let previewDiv = document.getElementById('pdf-preview-container-review')
+ if (!previewDiv) {
+ console.log("🔄 컨테이너 생성")
+ previewDiv = document.createElement('div')
+ previewDiv.id = 'pdf-preview-container-review'
+ previewDiv.className = 'w-full h-full'
+ previewDiv.style.width = '100%'
+ previewDiv.style.height = '100%'
+
+ // 실제 컨테이너에 추가
+ const actualContainer = document.querySelector('[data-pdf-container-review]')
+ if (actualContainer) {
+ actualContainer.appendChild(previewDiv)
+ }
+ }
+
+ console.log("🔄 WebViewer 인스턴스 생성 시작")
+
+ // WebViewer 인스턴스 생성 (문서 없이)
+ const instance = await Promise.race([
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ previewDiv
+ ),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
+ )
+ ])
+
+ console.log("🔄 WebViewer 인스턴스 생성 완료")
+ setPdfViewerInstance(instance)
+
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ console.log("🔄 PDF Blob URL 생성:", pdfUrl)
+
+ // 문서 로드
+ console.log("🔄 문서 로드 시작")
+ const { documentViewer } = instance.Core
+
+ // 문서 로드 이벤트 대기
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('문서 로드 타임아웃'))
+ }, 20000)
+
+ const onDocumentLoaded = () => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.log("🔄 문서 로드 완료")
+ resolve(true)
+ }
+
+ const onDocumentError = (error: Error) => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.error('문서 로드 오류:', error)
+ reject(error)
+ }
+
+ documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.addEventListener('documentError', onDocumentError)
+
+ // 문서 로드 시작
+ documentViewer.loadDocument(pdfUrl)
+ })
+
+ setIsPdfPreviewVisible(true)
+ toast.success('PDF 미리보기가 준비되었습니다.')
+
+ } catch (error) {
+ console.error('PDF 미리보기 실패:', error)
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'
+ toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${errorMessage}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 다운로드 기능
+ const downloadPdf = () => {
+ if (!generatedPdfBuffer) {
+ toast.error('다운로드할 PDF가 없습니다.')
+ return
+ }
+
+ const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+
+ const link = document.createElement('a')
+ link.href = pdfUrl
+ link.download = `contract_review_${contractId}_${Date.now()}.pdf`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+
+ URL.revokeObjectURL(pdfUrl)
+ toast.success('PDF가 다운로드되었습니다.')
+ }
+
+ // PDF 미리보기 닫기
+ const closePdfPreview = () => {
+ console.log("🔄 PDF 미리보기 닫기 시작")
+ if (pdfViewerInstance) {
+ try {
+ console.log("🔄 WebViewer 인스턴스 정리")
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 컨테이너 정리
+ const previewDiv = document.getElementById('pdf-preview-container-review')
+ if (previewDiv) {
+ try {
+ previewDiv.innerHTML = ''
+ } catch (error) {
+ console.warn('컨테이너 정리 중 오류:', error)
+ }
+ }
+
+ setIsPdfPreviewVisible(false)
+ console.log("🔄 PDF 미리보기 닫기 완료")
+ }
+
+ // 최종 전송
+ const handleFinalSubmit = async () => {
+ if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
+ toast.error('생성된 PDF가 필요합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 서버액션을 사용하여 조건검토요청 전송
+ const result = await sendContractReviewRequest(
+ contractSummary,
+ generatedPdfBuffer,
+ contractId,
+ userId
+ )
+
+ if (result.success) {
+ toast.success('조건검토요청이 전송되었습니다.')
+ onOpenChange(false)
+ // 페이지 새로고침을 위해 window.location.reload() 호출
+ window.location.reload()
+ } else {
+ // 서버에서 이미 처리된 에러 메시지 표시
+ toast.error(result.error || '조건검토요청 전송 실패')
+ return
+ }
+ } catch (error) {
+ console.error('Error submitting review request:', error)
+ toast.error('조건검토요청 전송 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 다이얼로그가 열릴 때 1단계 데이터 수집
+ useEffect(() => {
+ if (open && currentStep === 1) {
+ collectContractSummary()
+ }
+ }, [open, currentStep, collectContractSummary])
+
+ // 다이얼로그가 닫힐 때 PDF 뷰어 정리
+ useEffect(() => {
+ if (!open) {
+ closePdfPreview()
+ // 상태 초기화
+ setCurrentStep(1)
+ setUploadedFile(null)
+ setGeneratedPdfUrl(null)
+ setGeneratedPdfBuffer(null)
+ setIsPdfPreviewVisible(false)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open])
+
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 조건검토요청
+ </DialogTitle>
+ </DialogHeader>
+
+ <Tabs value={currentStep.toString()} className="w-full">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="1" disabled={currentStep < 1}>
+ 1. 미리보기
+ </TabsTrigger>
+ <TabsTrigger value="2" disabled={currentStep < 2}>
+ 2. 템플릿 업로드
+ </TabsTrigger>
+ <TabsTrigger value="3" disabled={currentStep < 3}>
+ 3. PDF 미리보기
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 1단계: 계약 현황 정리 */}
+ <TabsContent value="1" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <CheckCircle className="h-5 w-5 text-green-600" />
+ 작성된 계약 현황
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoading ? (
+ <div className="text-center py-4">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 기본 정보 (필수) */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">기본 정보</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
+ </div>
+ <div>
+ <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
+ </div>
+ <div>
+ <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
+ </div>
+ <div>
+ <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
+ </div>
+ </div>
+ </div>
+
+ {/* 지급/인도 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">지급/인도 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
+ </div>
+ <div>
+ <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
+ </div>
+ </div>
+ </div>
+
+ {/* 추가 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">추가 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약성립조건:</span>
+ {contractSummary?.basicInfo?.contractEstablishmentConditions &&
+ Object.entries(contractSummary.basicInfo.contractEstablishmentConditions)
+ .filter(([, value]) => value === true)
+ .map(([key]) => key)
+ .join(', ') || '없음'}
+ </div>
+ <div>
+ <span className="font-medium">계약해지조건:</span>
+ {contractSummary?.basicInfo?.contractTerminationConditions &&
+ Object.entries(contractSummary.basicInfo.contractTerminationConditions)
+ .filter(([, value]) => value === true)
+ .map(([key]) => key)
+ .join(', ') || '없음'}
+ </div>
+ </div>
+ </div>
+
+ {/* 품목 정보 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="items-enabled"
+ checked={contractSummary?.items && contractSummary.items.length > 0}
+ disabled
+ />
+ <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ {contractSummary?.items && contractSummary.items.length > 0 ? (
+ <div className="space-y-2">
+ <p className="text-sm text-muted-foreground">
+ 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
+ </p>
+ <div className="max-h-32 overflow-y-auto">
+ {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
+ <div key={index} className="text-xs bg-gray-50 p-2 rounded">
+ <div className="font-medium">{item.itemInfo || item.description || `품목 ${index + 1}`}</div>
+ <div className="text-muted-foreground">
+ 수량: {item.quantity || 0} | 단가: {item.contractUnitPrice || item.unitPrice || 0}
+ </div>
+ </div>
+ ))}
+ {contractSummary.items.length > 3 && (
+ <div className="text-xs text-muted-foreground text-center">
+ ... 외 {contractSummary.items.length - 3}개 품목
+ </div>
+ )}
+ </div>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ 품목 정보가 입력되지 않았습니다.
+ </p>
+ )}
+ </div>
+
+ {/* 하도급 체크리스트 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="subcontract-enabled"
+ checked={!!contractSummary?.subcontractChecklist}
+ disabled
+ />
+ <Label htmlFor="subcontract-enabled" className="font-medium">
+ 하도급 체크리스트
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.subcontractChecklist
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-end">
+ <Button
+ onClick={() => setCurrentStep(2)}
+ disabled={isLoading}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 2단계: 문서 업로드 */}
+ <TabsContent value="2" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5 text-blue-600" />
+ 계약서 템플릿 업로드
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="space-y-4">
+ <p className="text-lg text-muted-foreground">일반계약 표준문서 관리 페이지에 접속하여, 원하는 양식의 계약서를 다운받아 수정 후 업로드하세요.</p>
+ <div>
+ <Label htmlFor="file-upload-review">파일 업로드</Label>
+ <Input
+ id="file-upload-review"
+ type="file"
+ accept=".doc,.docx"
+ onChange={(e) => {
+ const file = e.target.files?.[0]
+ if (file) handleFileUpload(file)
+ }}
+ />
+ <p className="text-sm text-muted-foreground mt-1">
+ Word 문서(.doc, .docx) 파일만 업로드 가능합니다.
+ </p>
+ </div>
+
+ {uploadedFile && (
+ <div className="border rounded-lg p-4 bg-green-50">
+ <div className="flex items-center gap-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900">업로드 완료</span>
+ </div>
+ <p className="text-sm text-green-800 mt-1">{uploadedFile.name}</p>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(1)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={() => setCurrentStep(3)}
+ disabled={!uploadedFile}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 3단계: PDF 미리보기 */}
+ <TabsContent value="3" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5 text-purple-600" />
+ PDF 미리보기
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {!generatedPdfUrl ? (
+ <div className="text-center py-8">
+ <Button onClick={generatePdf} disabled={isLoading}>
+ {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
+ </Button>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="border rounded-lg p-4 bg-green-50">
+ <div className="flex items-center gap-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900">PDF 생성 완료</span>
+ </div>
+ </div>
+
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-4">
+ <h4 className="font-medium">생성된 PDF</h4>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={downloadPdf}
+ disabled={isLoading}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openPdfPreview}
+ disabled={isLoading}
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ 미리보기
+ </Button>
+ </div>
+ </div>
+
+ {/* PDF 미리보기 영역 */}
+ <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container-review>
+ {isPdfPreviewVisible ? (
+ <>
+ <div className="absolute top-2 right-2 z-10">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={closePdfPreview}
+ className="bg-white/90 hover:bg-white"
+ >
+ ✕ 닫기
+ </Button>
+ </div>
+ <div id="pdf-preview-container-review" className="w-full h-full" />
+ </>
+ ) : (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-2" />
+ <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(2)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={handleFinalSubmit}
+ disabled={!generatedPdfUrl || isLoading}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ <Send className="h-4 w-4 mr-2" />
+ {isLoading ? '전송 중...' : '조건검토요청 전송'}
+ </Button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/general-contracts/detail/general-contract-storage-info.tsx b/lib/general-contracts/detail/general-contract-storage-info.tsx
new file mode 100644
index 00000000..2c9b230c
--- /dev/null
+++ b/lib/general-contracts/detail/general-contract-storage-info.tsx
@@ -0,0 +1,249 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { Save, Plus, Trash2, LoaderIcon } from 'lucide-react'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+import { getStorageInfo, saveStorageInfo } from '../service'
+
+interface StorageInfoItem {
+ id?: number
+ poNumber: string
+ hullNumber: string
+ remainingAmount: number
+}
+
+interface ContractStorageInfoProps {
+ contractId: number
+ readOnly?: boolean
+}
+
+export function ContractStorageInfo({ contractId, readOnly = false }: ContractStorageInfoProps) {
+ const session = useSession()
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [items, setItems] = useState<StorageInfoItem[]>([])
+
+ // 데이터 로드
+ useEffect(() => {
+ const loadStorageInfo = async () => {
+ setIsLoading(true)
+ try {
+ const data = await getStorageInfo(contractId)
+ setItems(data || [])
+ } catch (error) {
+ console.error('Error loading storage info:', error)
+ toast.error('임치계약 정보를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (contractId) {
+ loadStorageInfo()
+ }
+ }, [contractId])
+
+ // 행 추가
+ const addRow = () => {
+ setItems(prev => [...prev, {
+ poNumber: '',
+ hullNumber: '',
+ remainingAmount: 0
+ }])
+ }
+
+ // 행 삭제
+ const deleteRow = (index: number) => {
+ setItems(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 항목 업데이트
+ const updateItem = (index: number, field: keyof StorageInfoItem, value: string | number) => {
+ setItems(prev => prev.map((item, i) =>
+ i === index ? { ...item, [field]: value } : item
+ ))
+ }
+
+ // 저장
+ const handleSave = async () => {
+ if (!userId) {
+ toast.error('사용자 정보를 찾을 수 없습니다.')
+ return
+ }
+
+ // 유효성 검사
+ const errors: string[] = []
+ items.forEach((item, index) => {
+ if (!item.poNumber.trim()) errors.push(`${index + 1}번째 행의 PO No.`)
+ if (!item.hullNumber.trim()) errors.push(`${index + 1}번째 행의 호선`)
+ if (item.remainingAmount < 0) errors.push(`${index + 1}번째 행의 미입고 잔여금액`)
+ })
+
+ if (errors.length > 0) {
+ toast.error(`다음 항목을 확인해주세요: ${errors.join(', ')}`)
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ await saveStorageInfo(contractId, items, userId)
+ toast.success('임치계약 정보가 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving storage info:', error)
+ toast.error('임치계약 정보 저장 중 오류가 발생했습니다.')
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>임치(물품보관)계약 상세 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <LoaderIcon className="w-6 h-6 animate-spin mr-2" />
+ <span>로딩 중...</span>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle>임치(물품보관)계약 상세 정보</CardTitle>
+ {!readOnly && (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={addRow}
+ >
+ <Plus className="w-4 h-4 mr-2" />
+ 행 추가
+ </Button>
+ <Button
+ onClick={handleSave}
+ disabled={isSaving}
+ >
+ {isSaving ? (
+ <>
+ <LoaderIcon className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent>
+ {items.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <p>등록된 정보가 없습니다.</p>
+ {!readOnly && (
+ <Button
+ variant="outline"
+ className="mt-4"
+ onClick={addRow}
+ >
+ <Plus className="w-4 h-4 mr-2" />
+ 정보 추가
+ </Button>
+ )}
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-12">번호</TableHead>
+ <TableHead>PO No.</TableHead>
+ <TableHead>호선</TableHead>
+ <TableHead className="text-right">미입고 잔여금액</TableHead>
+ {!readOnly && <TableHead className="w-20">삭제</TableHead>}
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {items.map((item, index) => (
+ <TableRow key={index}>
+ <TableCell>{index + 1}</TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="text-sm">{item.poNumber || '-'}</span>
+ ) : (
+ <Input
+ value={item.poNumber}
+ onChange={(e) => updateItem(index, 'poNumber', e.target.value)}
+ placeholder="PO No. 입력"
+ className="h-8 text-sm"
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="text-sm">{item.hullNumber || '-'}</span>
+ ) : (
+ <Input
+ value={item.hullNumber}
+ onChange={(e) => updateItem(index, 'hullNumber', e.target.value)}
+ placeholder="호선 입력"
+ className="h-8 text-sm"
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="text-sm text-right block">
+ {item.remainingAmount.toLocaleString()}
+ </span>
+ ) : (
+ <Input
+ type="number"
+ value={item.remainingAmount}
+ onChange={(e) => updateItem(index, 'remainingAmount', parseFloat(e.target.value) || 0)}
+ placeholder="0"
+ className="h-8 text-sm text-right"
+ min="0"
+ />
+ )}
+ </TableCell>
+ {!readOnly && (
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => deleteRow(index)}
+ className="text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ </TableCell>
+ )}
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx b/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx
index ce7c8baf..86c4485b 100644
--- a/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx
+++ b/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx
@@ -50,9 +50,21 @@ interface SubcontractChecklistProps {
onDataChange: (data: SubcontractChecklistData) => void
readOnly?: boolean
initialData?: SubcontractChecklistData
+ contractType?: string // 계약종류 (AD, AW, SG 등)
+ vendorCountry?: string // 협력업체 국가 (해외업체 여부 판단)
}
-export function SubcontractChecklist({ contractId, onDataChange, readOnly = false, initialData }: SubcontractChecklistProps) {
+export function SubcontractChecklist({
+ contractId,
+ onDataChange,
+ readOnly = false,
+ initialData,
+ contractType = '',
+ vendorCountry = ''
+}: SubcontractChecklistProps) {
+ // AD, AW, SG 계약 또는 해외업체인 경우 체크리스트 비활성화
+ const isChecklistDisabled = contractType === 'AD' || contractType === 'AW' || contractType === 'SG' || vendorCountry !== 'KR'
+
// 기본 데이터 구조
const defaultData: SubcontractChecklistData = {
contractDocumentIssuance: {
@@ -96,9 +108,16 @@ export function SubcontractChecklist({ contractId, onDataChange, readOnly = fals
}
}, [initialData])
- const [isEnabled, setIsEnabled] = useState(true)
+ const [isEnabled, setIsEnabled] = useState(!isChecklistDisabled)
const [data, setData] = useState<SubcontractChecklistData>(mergedInitialData)
+ // 체크리스트가 비활성화된 경우 경고 메시지 표시
+ React.useEffect(() => {
+ if (isChecklistDisabled) {
+ setIsEnabled(false)
+ }
+ }, [isChecklistDisabled])
+
// 점검결과 자동 계산 함수
const calculateInspectionResult = (
contractDocumentIssuance: SubcontractChecklistData['contractDocumentIssuance'],
@@ -249,20 +268,36 @@ export function SubcontractChecklist({ contractId, onDataChange, readOnly = fals
<div className="flex items-center gap-3 w-full">
<HelpCircle className="h-5 w-5" />
<span className="font-medium">하도급법 자율점검 체크리스트</span>
- <Badge className={resultInfo.color}>
- {resultInfo.label}
- </Badge>
+ {isChecklistDisabled ? (
+ <Badge className="bg-gray-100 text-gray-800">비활성화</Badge>
+ ) : (
+ <Badge className={resultInfo.color}>
+ {resultInfo.label}
+ </Badge>
+ )}
</div>
</AccordionTrigger>
<AccordionContent>
<Card>
<CardContent className="space-y-6 pt-6">
+ {/* 체크리스트 비활성화 안내 */}
+ {isChecklistDisabled && (
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ {contractType === 'AD' || contractType === 'AW' || contractType === 'SG'
+ ? `본 계약은 ${contractType === 'AD' ? '사전납품합의' : contractType === 'AW' ? '사전작업합의' : '임치(물품보관)계약'} 계약으로 하도급법 체크리스트가 적용되지 않습니다.`
+ : '해외업체 계약으로 하도급법 체크리스트가 적용되지 않습니다.'}
+ </AlertDescription>
+ </Alert>
+ )}
+
{/* 체크박스 */}
<div className="flex items-center gap-2">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => setIsEnabled(checked as boolean)}
- disabled={readOnly}
+ disabled={readOnly || isChecklistDisabled}
/>
<span className="text-sm font-medium">하도급법 자율점검 체크리스트 활성화</span>
</div>
diff --git a/lib/general-contracts/detail/general-contract-yard-entry-info.tsx b/lib/general-contracts/detail/general-contract-yard-entry-info.tsx
new file mode 100644
index 00000000..1fb1e310
--- /dev/null
+++ b/lib/general-contracts/detail/general-contract-yard-entry-info.tsx
@@ -0,0 +1,232 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Save, LoaderIcon } from 'lucide-react'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+import { getProjects, getYardEntryInfo, saveYardEntryInfo } from '../service'
+
+interface YardEntryInfo {
+ projectId: number | null
+ projectCode: string
+ projectName: string
+ managerName: string
+ managerDepartment: string
+ rehandlingContractor: string
+}
+
+interface ContractYardEntryInfoProps {
+ contractId: number
+ readOnly?: boolean
+}
+
+export function ContractYardEntryInfo({ contractId, readOnly = false }: ContractYardEntryInfoProps) {
+ const session = useSession()
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [projects, setProjects] = useState<Array<{ id: number; code: string; name: string }>>([])
+ const [formData, setFormData] = useState<YardEntryInfo>({
+ projectId: null,
+ projectCode: '',
+ projectName: '',
+ managerName: '',
+ managerDepartment: '',
+ rehandlingContractor: ''
+ })
+
+ // 프로젝트 목록 로드
+ useEffect(() => {
+ const loadProjects = async () => {
+ try {
+ const projectList = await getProjects()
+ setProjects(projectList)
+ } catch (error) {
+ console.error('Error loading projects:', error)
+ }
+ }
+ loadProjects()
+ }, [])
+
+ // 데이터 로드
+ useEffect(() => {
+ const loadYardEntryInfo = async () => {
+ setIsLoading(true)
+ try {
+ const data = await getYardEntryInfo(contractId)
+ if (data) {
+ setFormData(data)
+ }
+ } catch (error) {
+ console.error('Error loading yard entry info:', error)
+ toast.error('사외업체 야드투입 정보를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (contractId) {
+ loadYardEntryInfo()
+ }
+ }, [contractId])
+
+ // 저장
+ const handleSave = async () => {
+ if (!userId) {
+ toast.error('사용자 정보를 찾을 수 없습니다.')
+ return
+ }
+
+ // 유효성 검사
+ if (!formData.projectId) {
+ toast.error('프로젝트를 선택해주세요.')
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ await saveYardEntryInfo(contractId, formData, userId)
+ toast.success('사외업체 야드투입 정보가 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving yard entry info:', error)
+ toast.error('사외업체 야드투입 정보 저장 중 오류가 발생했습니다.')
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const selectedProject = projects.find(p => p.id === formData.projectId)
+
+ if (isLoading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>사외업체 야드투입 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <LoaderIcon className="w-6 h-6 animate-spin mr-2" />
+ <span>로딩 중...</span>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle>사외업체 야드투입 정보</CardTitle>
+ {!readOnly && (
+ <Button
+ onClick={handleSave}
+ disabled={isSaving}
+ >
+ {isSaving ? (
+ <>
+ <LoaderIcon className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 프로젝트 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="project">프로젝트 *</Label>
+ {readOnly ? (
+ <div className="text-sm">
+ {selectedProject ? `${selectedProject.code} - ${selectedProject.name}` : '-'}
+ </div>
+ ) : (
+ <Select
+ value={formData.projectId?.toString() || ''}
+ onValueChange={(value) => {
+ const projectId = parseInt(value)
+ const project = projects.find(p => p.id === projectId)
+ setFormData(prev => ({
+ ...prev,
+ projectId: projectId,
+ projectCode: project?.code || '',
+ projectName: project?.name || ''
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="프로젝트 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.code} - {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ </div>
+
+ {/* 관리담당자 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="managerName">관리담당자</Label>
+ {readOnly ? (
+ <div className="text-sm">{formData.managerName || '-'}</div>
+ ) : (
+ <Input
+ id="managerName"
+ value={formData.managerName}
+ onChange={(e) => setFormData(prev => ({ ...prev, managerName: e.target.value }))}
+ placeholder="관리담당자 입력"
+ />
+ )}
+ </div>
+
+ {/* 관리부서 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="managerDepartment">관리부서</Label>
+ {readOnly ? (
+ <div className="text-sm">{formData.managerDepartment || '-'}</div>
+ ) : (
+ <Input
+ id="managerDepartment"
+ value={formData.managerDepartment}
+ onChange={(e) => setFormData(prev => ({ ...prev, managerDepartment: e.target.value }))}
+ placeholder="관리부서 입력"
+ />
+ )}
+ </div>
+
+ {/* 재하도협력사 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="rehandlingContractor">재하도협력사</Label>
+ {readOnly ? (
+ <div className="text-sm">{formData.rehandlingContractor || '-'}</div>
+ ) : (
+ <Input
+ id="rehandlingContractor"
+ value={formData.rehandlingContractor}
+ onChange={(e) => setFormData(prev => ({ ...prev, rehandlingContractor: e.target.value }))}
+ placeholder="재하도협력사 입력"
+ />
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/lib/general-contracts/main/create-general-contract-dialog.tsx b/lib/general-contracts/main/create-general-contract-dialog.tsx
index 2c3fc8bc..168b8cbc 100644
--- a/lib/general-contracts/main/create-general-contract-dialog.tsx
+++ b/lib/general-contracts/main/create-general-contract-dialog.tsx
@@ -20,11 +20,12 @@ import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { CalendarIcon } from "lucide-react"
+import { CalendarIcon, Check, ChevronsUpDown } from "lucide-react"
+import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
import { format } from "date-fns"
import { ko } from "date-fns/locale"
import { cn } from "@/lib/utils"
-import { createContract, getVendors, getProjects } from "@/lib/general-contracts/service"
+import { createContract, getVendors } from "@/lib/general-contracts/service"
import {
GENERAL_CONTRACT_CATEGORIES,
GENERAL_CONTRACT_TYPES,
@@ -39,7 +40,6 @@ interface CreateContractForm {
type: string
executionMethod: string
vendorId: number | null
- projectId: number | null
startDate: Date | undefined
endDate: Date | undefined
validityEndDate: Date | undefined
@@ -52,7 +52,8 @@ export function CreateGeneralContractDialog() {
const [open, setOpen] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(false)
const [vendors, setVendors] = React.useState<Array<{ id: number; vendorName: string; vendorCode: string | null }>>([])
- const [projects, setProjects] = React.useState<Array<{ id: number; code: string; name: string; type: string }>>([])
+ const [vendorSearchTerm, setVendorSearchTerm] = React.useState("")
+ const [vendorOpen, setVendorOpen] = React.useState(false)
const [form, setForm] = React.useState<CreateContractForm>({
contractNumber: '',
@@ -61,7 +62,6 @@ export function CreateGeneralContractDialog() {
type: '',
executionMethod: '',
vendorId: null,
- projectId: null,
startDate: undefined,
endDate: undefined,
validityEndDate: undefined,
@@ -70,36 +70,54 @@ export function CreateGeneralContractDialog() {
// 업체 목록 조회
React.useEffect(() => {
- const fetchVendors = async () => {
+ const fetchData = async () => {
try {
const vendorList = await getVendors()
+ console.log('vendorList', vendorList)
setVendors(vendorList)
} catch (error) {
- console.error('Error fetching vendors:', error)
+ console.error('데이터 조회 오류:', error)
+ toast.error('데이터를 불러오는데 실패했습니다')
+ setVendors([])
}
}
- fetchVendors()
+ fetchData()
}, [])
- // 프로젝트 목록 조회
- React.useEffect(() => {
- const fetchProjects = async () => {
- try {
- const projectList = await getProjects()
- console.log(projectList)
- setProjects(projectList)
- } catch (error) {
- console.error('Error fetching projects:', error)
- }
- }
- fetchProjects()
- }, [])
+ // 협력업체 검색 필터링
+ const filteredVendors = React.useMemo(() => {
+ if (!vendorSearchTerm.trim()) return vendors
+ const lowerSearch = vendorSearchTerm.toLowerCase()
+ return vendors.filter(
+ vendor =>
+ vendor.vendorName.toLowerCase().includes(lowerSearch) ||
+ (vendor.vendorCode && vendor.vendorCode.toLowerCase().includes(lowerSearch))
+ )
+ }, [vendors, vendorSearchTerm])
const handleSubmit = async () => {
// 필수 필드 검증
- if (!form.name || !form.category || !form.type || !form.executionMethod ||
- !form.vendorId || !form.startDate || !form.endDate) {
- toast.error("필수 항목을 모두 입력해주세요.")
+ const validationErrors: string[] = []
+
+ if (!form.name) validationErrors.push('계약명')
+ if (!form.category) validationErrors.push('계약구분')
+ if (!form.type) validationErrors.push('계약종류')
+ if (!form.executionMethod) validationErrors.push('체결방식')
+ if (!form.vendorId) validationErrors.push('협력업체')
+
+ // AD, LO, OF 계약이 아닌 경우에만 계약기간 필수값 체크
+ if (!['AD', 'LO', 'OF'].includes(form.type)) {
+ if (!form.startDate) validationErrors.push('계약시작일')
+ if (!form.endDate) validationErrors.push('계약종료일')
+ }
+
+ // LO 계약인 경우 계약체결유효기간 필수값 체크
+ if (form.type === 'LO' && !form.validityEndDate) {
+ validationErrors.push('유효기간')
+ }
+
+ if (validationErrors.length > 0) {
+ toast.error(`다음 필수 항목을 입력해주세요: ${validationErrors.join(', ')}`)
return
}
@@ -116,7 +134,6 @@ export function CreateGeneralContractDialog() {
category: form.category,
type: form.type,
executionMethod: form.executionMethod,
- projectId: form.projectId,
contractSourceType: 'manual',
vendorId: form.vendorId!,
startDate: form.startDate!.toISOString().split('T')[0],
@@ -152,7 +169,6 @@ export function CreateGeneralContractDialog() {
type: '',
executionMethod: '',
vendorId: null,
- projectId: null,
startDate: undefined,
endDate: undefined,
validityEndDate: undefined,
@@ -231,15 +247,14 @@ export function CreateGeneralContractDialog() {
'AL': '연간운송계약',
'OS': '외주용역계약',
'OW': '도급계약',
- 'IS': '검사계약',
'LO': 'LOI',
'FA': 'FA',
'SC': '납품합의계약',
'OF': '클레임상계계약',
'AW': '사전작업합의',
'AD': '사전납품합의',
- 'AM': '설계계약',
- 'SC_SELL': '폐기물매각계약'
+ 'SG': '임치(물품보관)계약',
+ 'SR': '폐기물매각계약'
}
return (
<SelectItem key={type} value={type}>
@@ -269,35 +284,62 @@ export function CreateGeneralContractDialog() {
</div>
<div className="grid gap-2">
- <Label htmlFor="project">프로젝트</Label>
- <Select value={form.projectId?.toString()} onValueChange={(value) => setForm(prev => ({ ...prev, projectId: parseInt(value) }))}>
- <SelectTrigger>
- <SelectValue placeholder="프로젝트 선택 (선택사항)" />
- </SelectTrigger>
- <SelectContent>
- {projects.map((project) => (
- <SelectItem key={project.id} value={project.id.toString()}>
- {project.name} ({project.code})
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="grid gap-2">
<Label htmlFor="vendor">협력업체 *</Label>
- <Select value={form.vendorId?.toString()} onValueChange={(value) => setForm(prev => ({ ...prev, vendorId: parseInt(value) }))}>
- <SelectTrigger>
- <SelectValue placeholder="협력업체 선택" />
- </SelectTrigger>
- <SelectContent>
- {vendors.map((vendor) => (
- <SelectItem key={vendor.id} value={vendor.id.toString()}>
- {vendor.vendorName} ({vendor.vendorCode})
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className="w-full justify-between"
+ >
+ {form.vendorId ? (
+ (() => {
+ const selected = vendors.find(v => v.id === form.vendorId)
+ return selected ? `${selected.vendorName} (${selected.vendorCode || ''})` : "협력업체 선택"
+ })()
+ ) : (
+ "협력업체 선택"
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="협력업체명/코드 검색..."
+ onValueChange={setVendorSearchTerm}
+ />
+ <CommandList className="max-h-[300px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <CommandGroup>
+ {filteredVendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode || ''}`}
+ onSelect={() => {
+ setForm(prev => ({ ...prev, vendorId: vendor.id }))
+ setVendorOpen(false)
+ setVendorSearchTerm("")
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ form.vendorId === vendor.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="ml-2 text-gray-500">({vendor.vendorCode})</span>
+ )}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
<div className="grid grid-cols-3 gap-4">
diff --git a/lib/general-contracts/main/general-contract-update-sheet.tsx b/lib/general-contracts/main/general-contract-update-sheet.tsx
index 54f4ae4e..18095516 100644
--- a/lib/general-contracts/main/general-contract-update-sheet.tsx
+++ b/lib/general-contracts/main/general-contract-update-sheet.tsx
@@ -44,14 +44,41 @@ const updateContractSchema = z.object({
type: z.string().min(1, "계약종류를 선택해주세요"),
executionMethod: z.string().min(1, "체결방식을 선택해주세요"),
name: z.string().min(1, "계약명을 입력해주세요"),
- startDate: z.string().min(1, "계약시작일을 선택해주세요"),
- endDate: z.string().min(1, "계약종료일을 선택해주세요"),
- validityEndDate: z.string().min(1, "유효기간종료일을 선택해주세요"),
+ startDate: z.string().optional(), // AD, LO, OF 계약인 경우 선택사항
+ endDate: z.string().optional(), // AD, LO, OF 계약인 경우 선택사항
+ validityEndDate: z.string().optional(), // LO 계약인 경우에만 필수값으로 처리
contractScope: z.string().min(1, "계약확정범위를 선택해주세요"),
notes: z.string().optional(),
linkedRfqOrItb: z.string().optional(),
linkedPoNumber: z.string().optional(),
linkedBidNumber: z.string().optional(),
+}).superRefine((data, ctx) => {
+ // AD, LO, OF 계약이 아닌 경우 계약기간 필수값 체크
+ if (!['AD', 'LO', 'OF'].includes(data.type)) {
+ if (!data.startDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "계약시작일을 선택해주세요",
+ path: ["startDate"],
+ })
+ }
+ if (!data.endDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "계약종료일을 선택해주세요",
+ path: ["endDate"],
+ })
+ }
+ }
+
+ // LO 계약인 경우 계약체결유효기간 필수값 체크
+ if (data.type === 'LO' && !data.validityEndDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "LO 계약의 경우 계약체결유효기간은 필수 항목입니다",
+ path: ["validityEndDate"],
+ })
+ }
})
type UpdateContractFormData = z.infer<typeof updateContractSchema>
@@ -219,15 +246,14 @@ export function GeneralContractUpdateSheet({
'AL': '연간운송계약',
'OS': '외주용역계약',
'OW': '도급계약',
- 'IS': '검사계약',
'LO': 'LOI',
'FA': 'FA',
'SC': '납품합의계약',
'OF': '클레임상계계약',
'AW': '사전작업합의',
'AD': '사전납품합의',
- 'AM': '설계계약',
- 'SC_SELL': '폐기물매각계약'
+ 'SG': '임치(물품보관)계약',
+ 'SR': '폐기물매각계약'
}
return (
<SelectItem key={type} value={type}>
@@ -293,7 +319,10 @@ export function GeneralContractUpdateSheet({
name="startDate"
render={({ field }) => (
<FormItem>
- <FormLabel>계약시작일 *</FormLabel>
+ <FormLabel>
+ 계약시작일
+ {!['AD', 'LO', 'OF'].includes(form.watch('type')) && <span className="text-red-600 ml-1">*</span>}
+ </FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
@@ -308,7 +337,10 @@ export function GeneralContractUpdateSheet({
name="endDate"
render={({ field }) => (
<FormItem>
- <FormLabel>계약종료일 *</FormLabel>
+ <FormLabel>
+ 계약종료일
+ {!['AD', 'LO', 'OF'].includes(form.watch('type')) && <span className="text-red-600 ml-1">*</span>}
+ </FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
@@ -323,7 +355,10 @@ export function GeneralContractUpdateSheet({
name="validityEndDate"
render={({ field }) => (
<FormItem>
- <FormLabel>유효기간종료일 *</FormLabel>
+ <FormLabel>
+ 유효기간종료일
+ {form.watch('type') === 'LO' && <span className="text-red-600 ml-1">*</span>}
+ </FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
diff --git a/lib/general-contracts/main/general-contracts-table-columns.tsx b/lib/general-contracts/main/general-contracts-table-columns.tsx
index a08d8b81..932446d2 100644
--- a/lib/general-contracts/main/general-contracts-table-columns.tsx
+++ b/lib/general-contracts/main/general-contracts-table-columns.tsx
@@ -48,9 +48,6 @@ export interface GeneralContractListItem {
vendorId?: number
vendorName?: string
vendorCode?: string
- projectId?: number
- projectName?: string
- projectCode?: string
managerName?: string
lastUpdatedByName?: string
}
@@ -64,9 +61,6 @@ const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'Draft':
return 'outline'
- case 'Request to Review':
- case 'Confirm to Review':
- return 'secondary'
case 'Contract Accept Request':
return 'default'
case 'Complete the Contract':
@@ -84,10 +78,6 @@ const getStatusText = (status: string) => {
switch (status) {
case 'Draft':
return '임시저장'
- case 'Request to Review':
- return '조건검토요청'
- case 'Confirm to Review':
- return '조건검토완료'
case 'Contract Accept Request':
return '계약승인요청'
case 'Complete the Contract':
@@ -138,8 +128,6 @@ const getTypeText = (type: string) => {
return '외주용역계약'
case 'OW':
return '도급계약'
- case 'IS':
- return '검사계약'
case 'LO':
return 'LOI'
case 'FA':
@@ -152,9 +140,9 @@ const getTypeText = (type: string) => {
return '사전작업합의'
case 'AD':
return '사전납품합의'
- case 'AM':
- return '설계계약'
- case 'SC_SELL':
+ case 'SG':
+ return '임치(물품보관)계약'
+ case 'SR':
return '폐기물매각계약'
default:
return type
@@ -360,22 +348,6 @@ export function getGeneralContractsColumns({ setRowAction }: GetColumnsProps): C
size: 150,
meta: { excelHeader: "협력업체명" },
},
-
- {
- accessorKey: "projectName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
- cell: ({ row }) => (
- <div className="flex flex-col">
- <span className="font-medium">{row.original.projectName || '-'}</span>
- <span className="text-xs text-muted-foreground">
- {row.original.projectCode ? row.original.projectCode : "-"}
- </span>
- </div>
- ),
- size: 150,
- meta: { excelHeader: "프로젝트명" },
- },
-
]
},
diff --git a/lib/general-contracts/main/general-contracts-table.tsx b/lib/general-contracts/main/general-contracts-table.tsx
index e4c96ee3..503527b3 100644
--- a/lib/general-contracts/main/general-contracts-table.tsx
+++ b/lib/general-contracts/main/general-contracts-table.tsx
@@ -49,15 +49,14 @@ const contractTypeLabels = {
'AL': '연간운송계약',
'OS': '외주용역계약',
'OW': '도급계약',
- 'IS': '검사계약',
'LO': 'LOI',
'FA': 'FA',
'SC': '납품합의계약',
'OF': '클레임상계계약',
'AW': '사전작업합의',
'AD': '사전납품합의',
- 'AM': '설계계약',
- 'SC_SELL': '폐기물매각계약'
+ 'SG': '임치(물품보관)계약',
+ 'SR': '폐기물매각계약'
}
interface GeneralContractsTableProps {
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 2422706a..77593f29 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -9,7 +9,7 @@ import { generalContracts, generalContractItems, generalContractAttachments } fr
import { contracts, contractItems, contractEnvelopes, contractSigners } from '@/db/schema/contract'
import { basicContract, basicContractTemplates } from '@/db/schema/basicContractDocumnet'
import { vendors } from '@/db/schema/vendors'
-import { users } from '@/db/schema/users'
+import { users, roles, userRoles } from '@/db/schema/users'
import { projects } from '@/db/schema/projects'
import { items } from '@/db/schema/items'
import { filterColumns } from '@/lib/filter-columns'
@@ -225,10 +225,6 @@ export async function getGeneralContracts(input: GetGeneralContractsSchema) {
vendorId: generalContracts.vendorId,
vendorName: vendors.vendorName,
vendorCode: vendors.vendorCode,
- // Project info
- projectId: generalContracts.projectId,
- projectName: projects.name,
- projectCode: projects.code,
// User info
managerName: users.name,
lastUpdatedByName: users.name,
@@ -236,7 +232,6 @@ export async function getGeneralContracts(input: GetGeneralContractsSchema) {
.from(generalContracts)
.leftJoin(vendors, eq(generalContracts.vendorId, vendors.id))
.leftJoin(users, eq(generalContracts.registeredById, users.id))
- .leftJoin(projects, eq(generalContracts.projectId, projects.id))
.where(finalWhere)
.orderBy(...orderByColumns)
.limit(input.perPage)
@@ -287,13 +282,9 @@ export async function getContractById(id: number) {
.from(vendors)
.where(eq(vendors.id, contract[0].vendorId))
.limit(1)
-
- // Get project info
- const project = contract[0].projectId ? await db
- .select()
- .from(projects)
- .where(eq(projects.id, contract[0].projectId))
- .limit(1) : null
+
+ // vendor의 country 정보 가져오기 (없으면 기본값 'KR')
+ const vendorCountry = vendor[0]?.country || 'KR'
// Get manager info
const manager = await db
@@ -309,9 +300,7 @@ export async function getContractById(id: number) {
vendor: vendor[0] || null,
vendorCode: vendor[0]?.vendorCode || null,
vendorName: vendor[0]?.vendorName || null,
- project: project ? project[0] : null,
- projectName: project ? project[0].name : null,
- projectCode: project ? project[0].code : null,
+ vendorCountry: vendorCountry,
manager: manager[0] || null
}
} catch (error) {
@@ -392,7 +381,6 @@ export async function createContract(data: Record<string, unknown>) {
executionMethod: data.executionMethod as string,
name: data.name as string,
vendorId: data.vendorId as number,
- projectId: data.projectId as number,
startDate: data.startDate as string,
endDate: data.endDate as string,
validityEndDate: data.validityEndDate as string,
@@ -424,10 +412,6 @@ export async function createContract(data: Record<string, unknown>) {
contractTerminationConditions: data.contractTerminationConditions || {},
terms: data.terms || {},
complianceChecklist: data.complianceChecklist || {},
- communicationChannels: data.communicationChannels || {},
- locations: data.locations || {},
- fieldServiceRates: data.fieldServiceRates || {},
- offsetDetails: data.offsetDetails || {},
totalAmount: data.totalAmount as number,
availableBudget: data.availableBudget as number,
registeredById: data.registeredById as number,
@@ -451,6 +435,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
// 업데이트할 데이터 정리
// 클라이언트에서 전송된 formData를 그대로 사용합니다.
const {
+ contractScope,
specificationType,
specificationManualText,
unitPriceType,
@@ -475,6 +460,8 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
interlockingSystem,
mandatoryDocuments,
contractTerminationConditions,
+ externalYardEntry,
+ contractAmountReason,
} = data
// 계약금액 자동 집계 로직
@@ -507,6 +494,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
// 업데이트할 데이터 객체 생성
const updateData: Record<string, unknown> = {
+ contractScope,
specificationType,
specificationManualText,
unitPriceType,
@@ -531,6 +519,8 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
interlockingSystem,
mandatoryDocuments, // JSON 필드
contractTerminationConditions, // JSON 필드
+ externalYardEntry,
+ contractAmountReason: convertEmptyStringToNull(contractAmountReason),
contractAmount: calculatedContractAmount || 0,
lastUpdatedAt: new Date(),
lastUpdatedById: userId,
@@ -543,6 +533,12 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
.where(eq(generalContracts.id, id))
.returning()
+ // 계약명 I/F 로직 (39번 화면으로의 I/F)
+ // TODO: 39번 화면의 정확한 API 엔드포인트나 함수명 확인 필요
+ // if (data.name) {
+ // await syncContractNameToScreen39(id, data.name as string)
+ // }
+
revalidatePath('/general-contracts')
revalidatePath(`/general-contracts/detail/${id}`)
return updatedContract
@@ -556,8 +552,28 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
export async function getContractItems(contractId: number) {
try {
const items = await db
- .select()
+ .select({
+ id: generalContractItems.id,
+ contractId: generalContractItems.contractId,
+ projectId: generalContractItems.projectId,
+ itemCode: generalContractItems.itemCode,
+ itemInfo: generalContractItems.itemInfo,
+ specification: generalContractItems.specification,
+ quantity: generalContractItems.quantity,
+ quantityUnit: generalContractItems.quantityUnit,
+ totalWeight: generalContractItems.totalWeight,
+ weightUnit: generalContractItems.weightUnit,
+ contractDeliveryDate: generalContractItems.contractDeliveryDate,
+ contractUnitPrice: generalContractItems.contractUnitPrice,
+ contractAmount: generalContractItems.contractAmount,
+ contractCurrency: generalContractItems.contractCurrency,
+ createdAt: generalContractItems.createdAt,
+ updatedAt: generalContractItems.updatedAt,
+ projectName: projects.name,
+ projectCode: projects.code,
+ })
.from(generalContractItems)
+ .leftJoin(projects, eq(generalContractItems.projectId, projects.id))
.where(eq(generalContractItems.contractId, contractId))
.orderBy(asc(generalContractItems.id))
@@ -575,6 +591,7 @@ export async function createContractItem(contractId: number, itemData: Record<st
.insert(generalContractItems)
.values({
contractId,
+ projectId: itemData.projectId ? (itemData.projectId as number) : null,
itemCode: itemData.itemCode as string,
itemInfo: itemData.itemInfo as string,
specification: itemData.specification as string,
@@ -604,6 +621,7 @@ export async function updateContractItem(itemId: number, itemData: Record<string
const [updatedItem] = await db
.update(generalContractItems)
.set({
+ projectId: itemData.projectId ? (itemData.projectId as number) : null,
itemCode: itemData.itemCode as string,
itemInfo: itemData.itemInfo as string,
specification: itemData.specification as string,
@@ -673,6 +691,7 @@ export async function updateContractItems(contractId: number, items: Record<stri
.values(
items.map((item: Record<string, unknown>) => ({
contractId,
+ projectId: item.projectId ? (item.projectId as number) : null,
itemCode: item.itemCode as string,
itemInfo: item.itemInfo as string,
specification: item.specification as string,
@@ -829,7 +848,8 @@ export async function getBasicInfo(contractId: number) {
contractEstablishmentConditions: contract.contractEstablishmentConditions,
interlockingSystem: contract.interlockingSystem,
mandatoryDocuments: contract.mandatoryDocuments,
- contractTerminationConditions: contract.contractTerminationConditions
+ contractTerminationConditions: contract.contractTerminationConditions,
+ externalYardEntry: contract.externalYardEntry || 'N'
}
}
} catch (error) {
@@ -838,87 +858,6 @@ export async function getBasicInfo(contractId: number) {
}
}
-
-export async function getCommunicationChannel(contractId: number) {
- try {
- const [contract] = await db
- .select({
- communicationChannels: generalContracts.communicationChannels
- })
- .from(generalContracts)
- .where(eq(generalContracts.id, contractId))
- .limit(1)
-
- if (!contract) {
- return null
- }
-
- return contract.communicationChannels as any
- } catch (error) {
- console.error('Error getting communication channel:', error)
- throw new Error('Failed to get communication channel')
- }
-}
-
-export async function updateCommunicationChannel(contractId: number, communicationData: Record<string, unknown>, userId: number) {
- try {
- await db
- .update(generalContracts)
- .set({
- communicationChannels: communicationData,
- lastUpdatedAt: new Date(),
- lastUpdatedById: userId
- })
- .where(eq(generalContracts.id, contractId))
-
- revalidatePath('/general-contracts')
- return { success: true }
- } catch (error) {
- console.error('Error updating communication channel:', error)
- throw new Error('Failed to update communication channel')
- }
-}
-
-export async function updateLocation(contractId: number, locationData: Record<string, unknown>, userId: number) {
- try {
- await db
- .update(generalContracts)
- .set({
- locations: locationData,
- lastUpdatedAt: new Date(),
- lastUpdatedById: userId
- })
- .where(eq(generalContracts.id, contractId))
-
- revalidatePath('/general-contracts')
- return { success: true }
- } catch (error) {
- console.error('Error updating location:', error)
- throw new Error('Failed to update location')
- }
-}
-
-export async function getLocation(contractId: number) {
- try {
- const [contract] = await db
- .select({
- locations: generalContracts.locations
- })
- .from(generalContracts)
- .where(eq(generalContracts.id, contractId))
- .limit(1)
-
- if (!contract) {
- return null
- }
-
- return contract.locations as any
- } catch (error) {
- console.error('Error getting location:', error)
- throw new Error('Failed to get location')
- }
-}
-
export async function updateContract(id: number, data: Record<string, unknown>) {
try {
// 숫자 필드에서 빈 문자열을 null로 변환
@@ -990,7 +929,7 @@ export async function updateContract(id: number, data: Record<string, unknown>)
.insert(generalContractItems)
.values(
data.contractItems.map((item: any) => ({
- project: item.project,
+ projectId: item.projectId ? (item.projectId as number) : null,
itemCode: item.itemCode,
itemInfo: item.itemInfo,
specification: item.specification,
@@ -1452,6 +1391,49 @@ export async function sendContractApprovalRequest(
signerStatus: 'PENDING',
})
+ // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정
+ if (contractSummary.basicInfo?.externalYardEntry === 'Y') {
+ try {
+ // 안전담당자 역할을 가진 사용자 조회 (역할명에 '안전' 또는 'safety' 포함)
+ const safetyManagers = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ })
+ .from(users)
+ .innerJoin(userRoles, eq(users.id, userRoles.userId))
+ .innerJoin(roles, eq(userRoles.roleId, roles.id))
+ .where(
+ and(
+ or(
+ like(roles.name, '%안전%'),
+ like(roles.name, '%safety%'),
+ like(roles.name, '%Safety%')
+ ),
+ eq(users.isActive, true)
+ )
+ )
+ .limit(1)
+
+ // 첫 번째 안전담당자를 자동 추가
+ if (safetyManagers.length > 0) {
+ const safetyManager = safetyManagers[0]
+ await db.insert(contractSigners).values({
+ envelopeId: newEnvelope.id,
+ signerType: 'SAFETY_MANAGER',
+ signerEmail: safetyManager.email || '',
+ signerName: safetyManager.name || '안전담당자',
+ signerPosition: '안전담당자',
+ signerStatus: 'PENDING',
+ })
+ }
+ } catch (error) {
+ console.error('Error adding safety manager:', error)
+ // 안전담당자 추가 실패해도 계약 승인 요청은 계속 진행
+ }
+ }
+
// generalContractAttachments에 contractId 업데이트 (일반계약의 첨부파일들을 PO 계약과 연결)
const generalContractId = contractSummary.basicInfo?.id || contractSummary.id
if (generalContractId) {
@@ -1705,93 +1687,142 @@ async function mapContractSummaryToDb(contractSummary: any) {
}
}
-// Field Service Rate 관련 서버 액션들
-export async function getFieldServiceRate(contractId: number) {
+
+// 계약번호 생성 함수
+// 임치계약 정보 조회
+export async function getStorageInfo(contractId: number) {
try {
- const result = await db
- .select({ fieldServiceRates: generalContracts.fieldServiceRates })
+ const contract = await db
+ .select({ terms: generalContracts.terms })
.from(generalContracts)
.where(eq(generalContracts.id, contractId))
.limit(1)
- if (result.length === 0) {
- return null
+ if (!contract.length || !contract[0].terms) {
+ return []
}
- return result[0].fieldServiceRates as Record<string, unknown> || null
+ const terms = contract[0].terms as any
+ return terms.storageInfo || []
} catch (error) {
- console.error('Failed to get field service rate:', error)
- throw new Error('Field Service Rate 데이터를 불러오는데 실패했습니다.')
+ console.error('Error getting storage info:', error)
+ throw new Error('Failed to get storage info')
}
}
-export async function updateFieldServiceRate(
- contractId: number,
- fieldServiceRateData: Record<string, unknown>,
- userId: number
-) {
+// 임치계약 정보 저장
+export async function saveStorageInfo(contractId: number, items: Array<{ poNumber: string; hullNumber: string; remainingAmount: number }>, userId: number) {
try {
+ const contract = await db
+ .select({ terms: generalContracts.terms })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract.length) {
+ throw new Error('Contract not found')
+ }
+
+ const currentTerms = (contract[0].terms || {}) as any
+ const updatedTerms = {
+ ...currentTerms,
+ storageInfo: items
+ }
+
await db
.update(generalContracts)
.set({
- fieldServiceRates: fieldServiceRateData,
+ terms: updatedTerms,
lastUpdatedAt: new Date(),
- lastUpdatedById: userId
+ lastUpdatedById: userId,
})
.where(eq(generalContracts.id, contractId))
- revalidatePath('/evcp/general-contracts')
- return { success: true }
+ revalidatePath(`/general-contracts/detail/${contractId}`)
} catch (error) {
- console.error('Failed to update field service rate:', error)
- throw new Error('Field Service Rate 업데이트에 실패했습니다.')
+ console.error('Error saving storage info:', error)
+ throw new Error('Failed to save storage info')
}
}
-// Offset Details 관련 서버 액션들
-export async function getOffsetDetails(contractId: number) {
+// 야드투입 정보 조회
+export async function getYardEntryInfo(contractId: number) {
try {
- const result = await db
- .select({ offsetDetails: generalContracts.offsetDetails })
+ const contract = await db
+ .select({ terms: generalContracts.terms })
.from(generalContracts)
.where(eq(generalContracts.id, contractId))
.limit(1)
- if (result.length === 0) {
+ if (!contract.length || !contract[0].terms) {
return null
}
- return result[0].offsetDetails as Record<string, unknown> || null
+ const terms = contract[0].terms as any
+ return terms.yardEntryInfo || null
} catch (error) {
- console.error('Failed to get offset details:', error)
- throw new Error('회입/상계내역 데이터를 불러오는데 실패했습니다.')
+ console.error('Error getting yard entry info:', error)
+ throw new Error('Failed to get yard entry info')
}
}
-export async function updateOffsetDetails(
- contractId: number,
- offsetDetailsData: Record<string, unknown>,
- userId: number
-) {
+// 야드투입 정보 저장
+export async function saveYardEntryInfo(contractId: number, data: { projectId: number | null; projectCode: string; projectName: string; managerName: string; managerDepartment: string; rehandlingContractor: string }, userId: number) {
try {
+ const contract = await db
+ .select({ terms: generalContracts.terms })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract.length) {
+ throw new Error('Contract not found')
+ }
+
+ const currentTerms = (contract[0].terms || {}) as any
+ const updatedTerms = {
+ ...currentTerms,
+ yardEntryInfo: data
+ }
+
await db
.update(generalContracts)
.set({
- offsetDetails: offsetDetailsData,
+ terms: updatedTerms,
lastUpdatedAt: new Date(),
- lastUpdatedById: userId
+ lastUpdatedById: userId,
})
.where(eq(generalContracts.id, contractId))
- revalidatePath('/evcp/general-contracts')
- return { success: true }
+ revalidatePath(`/general-contracts/detail/${contractId}`)
} catch (error) {
- console.error('Failed to update offset details:', error)
- throw new Error('회입/상계내역 업데이트에 실패했습니다.')
+ console.error('Error saving yard entry info:', error)
+ throw new Error('Failed to save yard entry info')
+ }
+}
+
+// 계약 문서 댓글 저장
+export async function saveContractAttachmentComment(attachmentId: number, contractId: number, commentType: 'shi' | 'vendor', comment: string, userId: number) {
+ try {
+ const updateData: Record<string, unknown> = {}
+ if (commentType === 'shi') {
+ updateData.shiComment = comment
+ } else {
+ updateData.vendorComment = comment
+ }
+
+ await db
+ .update(generalContractAttachments)
+ .set(updateData)
+ .where(eq(generalContractAttachments.id, attachmentId))
+
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+ } catch (error) {
+ console.error('Error saving attachment comment:', error)
+ throw new Error('Failed to save attachment comment')
}
}
-// 계약번호 생성 함수
export async function generateContractNumber(
userId?: string,
contractType: string
@@ -1805,15 +1836,14 @@ export async function generateContractNumber(
'AL': 'AL', // 연간운송계약
'OS': 'OS', // 외주용역계약
'OW': 'OW', // 도급계약
- 'IS': 'IS', // 검사계약
'LO': 'LO', // LOI (의향서)
'FA': 'FA', // FA (Frame Agreement)
'SC': 'SC', // 납품합의계약 (Supply Contract)
'OF': 'OF', // 클레임상계계약 (Offset Agreement)
'AW': 'AW', // 사전작업합의 (Advanced Work)
'AD': 'AD', // 사전납품합의 (Advanced Delivery)
- 'AM': 'AM', // 설계계약
- 'SC_SELL': 'SC' // 폐기물매각계약 (Scrap) - 납품합의계약과 동일한 코드 사용
+ 'SG': 'SG', // 임치(물품보관)계약
+ 'SR': 'SR' // 폐기물매각계약 (Scrap)
}
const typeCode = contractTypeMap[contractType] || 'XX' // 기본값
@@ -1912,7 +1942,7 @@ export async function generateContractNumber(
}
}
-// 프로젝트 목록 조회
+// 프로젝트 목록 조회 (코드와 이름만 반환)
export async function getProjects() {
try {
const projectList = await db
@@ -1920,14 +1950,782 @@ export async function getProjects() {
id: projects.id,
code: projects.code,
name: projects.name,
- type: projects.type,
})
.from(projects)
.orderBy(asc(projects.name))
return projectList
} catch (error) {
- console.error('Error fetching projects:', error)
- throw new Error('Failed to fetch projects')
+ console.error('프로젝트 목록 조회 오류:', error)
+ throw new Error('프로젝트 목록을 불러오는데 실패했습니다')
}
}
+
+// ═══════════════════════════════════════════════════════════════
+// 협력업체 전용 조건검토 조회 함수
+// ═══════════════════════════════════════════════════════════════
+
+// 협력업체 전용 조건검토 계약 조회
+export async function getVendorContractReviews(
+ vendorId: number,
+ page: number = 1,
+ perPage: number = 10,
+ search?: string
+) {
+ try {
+ const offset = (page - 1) * perPage
+
+ // 조건검토 관련 상태들
+ const reviewStatuses = ['Request to Review', 'Vendor Replied Review', 'SHI Confirmed Review']
+
+ // 기본 조건: vendorId와 status 필터
+ const conditions: SQL<unknown>[] = [
+ eq(generalContracts.vendorId, vendorId),
+ or(...reviewStatuses.map(status => eq(generalContracts.status, status)))!
+ ]
+
+ // 검색 조건 추가
+ if (search) {
+ const searchPattern = `%${search}%`
+ conditions.push(
+ or(
+ ilike(generalContracts.contractNumber, searchPattern),
+ ilike(generalContracts.name, searchPattern),
+ ilike(generalContracts.notes, searchPattern)
+ )!
+ )
+ }
+
+ const whereCondition = and(...conditions)
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(generalContracts)
+ .where(whereCondition)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ id: generalContracts.id,
+ contractNumber: generalContracts.contractNumber,
+ revision: generalContracts.revision,
+ status: generalContracts.status,
+ category: generalContracts.category,
+ type: generalContracts.type,
+ executionMethod: generalContracts.executionMethod,
+ name: generalContracts.name,
+ contractSourceType: generalContracts.contractSourceType,
+ startDate: generalContracts.startDate,
+ endDate: generalContracts.endDate,
+ validityEndDate: generalContracts.validityEndDate,
+ contractScope: generalContracts.contractScope,
+ specificationType: generalContracts.specificationType,
+ specificationManualText: generalContracts.specificationManualText,
+ contractAmount: generalContracts.contractAmount,
+ totalAmount: generalContracts.totalAmount,
+ currency: generalContracts.currency,
+ registeredAt: generalContracts.registeredAt,
+ signedAt: generalContracts.signedAt,
+ linkedRfqOrItb: generalContracts.linkedRfqOrItb,
+ linkedPoNumber: generalContracts.linkedPoNumber,
+ linkedBidNumber: generalContracts.linkedBidNumber,
+ lastUpdatedAt: generalContracts.lastUpdatedAt,
+ notes: generalContracts.notes,
+ vendorId: generalContracts.vendorId,
+ registeredById: generalContracts.registeredById,
+ lastUpdatedById: generalContracts.lastUpdatedById,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ managerName: users.name,
+ })
+ .from(generalContracts)
+ .leftJoin(vendors, eq(generalContracts.vendorId, vendors.id))
+ .leftJoin(users, eq(generalContracts.registeredById, users.id))
+ .where(whereCondition)
+ .orderBy(desc(generalContracts.registeredAt))
+ .limit(perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / perPage)
+
+ // 날짜 변환 헬퍼 함수
+ const formatDate = (date: unknown): string => {
+ if (!date) return ''
+ if (date instanceof Date) {
+ return date.toISOString()
+ }
+ if (typeof date === 'string') {
+ return date
+ }
+ return String(date)
+ }
+
+ return {
+ data: data.map((row) => ({
+ id: row.id,
+ contractNumber: row.contractNumber || '',
+ revision: row.revision || 0,
+ status: row.status || '',
+ category: row.category || '',
+ type: row.type || '',
+ executionMethod: row.executionMethod || '',
+ name: row.name || '',
+ contractSourceType: row.contractSourceType || '',
+ startDate: formatDate(row.startDate),
+ endDate: formatDate(row.endDate),
+ validityEndDate: formatDate(row.validityEndDate),
+ contractScope: row.contractScope || '',
+ specificationType: row.specificationType || '',
+ specificationManualText: row.specificationManualText || '',
+ contractAmount: row.contractAmount ? row.contractAmount.toString() : '',
+ totalAmount: row.totalAmount ? row.totalAmount.toString() : '',
+ currency: row.currency || '',
+ registeredAt: formatDate(row.registeredAt),
+ signedAt: formatDate(row.signedAt),
+ linkedRfqOrItb: row.linkedRfqOrItb || '',
+ linkedPoNumber: row.linkedPoNumber || '',
+ linkedBidNumber: row.linkedBidNumber || '',
+ lastUpdatedAt: formatDate(row.lastUpdatedAt),
+ notes: row.notes || '',
+ vendorId: row.vendorId || 0,
+ registeredById: row.registeredById || 0,
+ lastUpdatedById: row.lastUpdatedById || 0,
+ vendorName: row.vendorName || '',
+ vendorCode: row.vendorCode || '',
+ managerName: row.managerName || '',
+ })),
+ pageCount,
+ total,
+ }
+ console.log(data, "data")
+ } catch (error) {
+ console.error('Error fetching vendor contract reviews:', error)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// 조건검토 의견 관련 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// 협력업체 조건검토 의견 저장
+export async function saveVendorComment(
+ contractId: number,
+ vendorComment: string,
+ vendorId: number
+) {
+ try {
+ // 계약 정보 조회 및 권한 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ if (contract.vendorId !== vendorId) {
+ throw new Error('이 계약에 대한 접근 권한이 없습니다.')
+ }
+
+ // generalContracts 테이블에 vendorComment 저장
+ await db
+ .update(generalContracts)
+ .set({
+ vendorComment: vendorComment,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: contract.lastUpdatedById, // 기존 수정자 유지
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath(`/partners/general-contract-review/${contractId}`)
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '협력업체 의견이 저장되었습니다.' }
+ } catch (error) {
+ console.error('협력업체 의견 저장 오류:', error)
+ throw error
+ }
+}
+
+// 협력업체 조건검토 의견 조회
+export async function getVendorComment(contractId: number, vendorId?: number) {
+ try {
+ const conditions = [eq(generalContracts.id, contractId)]
+
+ if (vendorId) {
+ conditions.push(eq(generalContracts.vendorId, vendorId))
+ }
+
+ const [contract] = await db
+ .select({
+ vendorComment: generalContracts.vendorComment,
+ })
+ .from(generalContracts)
+ .where(and(...conditions))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ return {
+ success: true,
+ vendorComment: contract.vendorComment || '',
+ }
+ } catch (error) {
+ console.error('협력업체 의견 조회 오류:', error)
+ throw error
+ }
+}
+
+// 당사 조건검토 의견 저장
+export async function saveShiComment(
+ contractId: number,
+ shiComment: string,
+ userId: number
+) {
+ try {
+ // 계약 정보 조회
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // generalContracts 테이블에 shiComment 저장
+ await db
+ .update(generalContracts)
+ .set({
+ shiComment: shiComment,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId,
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+ revalidatePath(`/partners/general-contract-review/${contractId}`)
+
+ return { success: true, message: '당사 의견이 저장되었습니다.' }
+ } catch (error) {
+ console.error('당사 의견 저장 오류:', error)
+ throw error
+ }
+}
+
+// 당사 조건검토 의견 조회
+export async function getShiComment(contractId: number) {
+ try {
+ const [contract] = await db
+ .select({
+ shiComment: generalContracts.shiComment,
+ })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ return {
+ success: true,
+ shiComment: contract.shiComment || '',
+ }
+ } catch (error) {
+ console.error('당사 의견 조회 오류:', error)
+ throw error
+ }
+}
+
+// 조건검토 의견 모두 조회 (vendorComment + shiComment)
+export async function getContractReviewComments(contractId: number, vendorId?: number) {
+ try {
+ const conditions = [eq(generalContracts.id, contractId)]
+
+ if (vendorId) {
+ conditions.push(eq(generalContracts.vendorId, vendorId))
+ }
+
+ const [contract] = await db
+ .select({
+ vendorComment: generalContracts.vendorComment,
+ shiComment: generalContracts.shiComment,
+ })
+ .from(generalContracts)
+ .where(and(...conditions))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ return {
+ success: true,
+ vendorComment: contract.vendorComment || '',
+ shiComment: contract.shiComment || '',
+ }
+ } catch (error) {
+ console.error('조건검토 의견 조회 오류:', error)
+ throw error
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// 조건검토요청 관련 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// 조건검토용 파일 업로드
+export async function uploadContractReviewFile(contractId: number, file: File, userId: string) {
+ try {
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `general-contracts/${contractId}/review-documents`,
+ userId,
+ )
+
+ if (saveResult.success && saveResult.publicPath) {
+ return {
+ success: true,
+ message: '파일이 성공적으로 업로드되었습니다.',
+ filePath: saveResult.publicPath,
+ fileName: saveResult.fileName || file.name
+ }
+ } else {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+ } catch (error) {
+ console.error('Failed to upload contract review file:', error)
+ return {
+ success: false,
+ error: '파일 업로드에 실패했습니다.'
+ }
+ }
+}
+
+// 조건검토요청 전송 (PDF 포함)
+export async function sendContractReviewRequest(
+ contractSummary: any,
+ pdfBuffer: Uint8Array,
+ contractId: number,
+ userId: string
+) {
+ try {
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ // 계약 정보 조회
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // PDF 버퍼를 saveBuffer 함수로 저장
+ const fileId = uuidv4()
+ const fileName = `contract_review_${fileId}.pdf`
+
+ // PDF 버퍼를 Buffer로 변환
+ let bufferData: Buffer
+ if (Buffer.isBuffer(pdfBuffer)) {
+ bufferData = pdfBuffer
+ } else if (pdfBuffer instanceof ArrayBuffer) {
+ bufferData = Buffer.from(pdfBuffer)
+ } else if (pdfBuffer instanceof Uint8Array) {
+ bufferData = Buffer.from(pdfBuffer)
+ } else {
+ bufferData = Buffer.from(pdfBuffer as any)
+ }
+
+ // saveBuffer 함수를 사용해서 파일 저장
+ const saveResult = await saveBuffer({
+ buffer: bufferData,
+ fileName: fileName,
+ directory: "generalContracts",
+ originalName: `contract_review_${contractId}_${fileId}.pdf`,
+ userId: userId
+ })
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || 'PDF 파일 저장에 실패했습니다.')
+ }
+
+ const finalFileName = saveResult.fileName || fileName
+ const finalFilePath = saveResult.publicPath
+ ? saveResult.publicPath.replace('/api/files/', '')
+ : `/generalContracts/${fileName}`
+
+ // generalContractAttachments 테이블에 계약서 초안 PDF 저장
+ await db.insert(generalContractAttachments).values({
+ contractId: contractId,
+ documentName: '계약서 초안',
+ fileName: finalFileName,
+ filePath: finalFilePath,
+ uploadedById: userIdNumber,
+ uploadedAt: new Date(),
+ })
+
+ // 계약 상태를 'Request to Review'로 변경
+ await db
+ .update(generalContracts)
+ .set({
+ status: 'Request to Review',
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userIdNumber,
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ // 협력업체 정보 조회
+ const [vendor] = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, contract.vendorId))
+ .limit(1)
+
+ // 협력업체 담당자에게 검토 요청 이메일 발송
+ if (vendor?.vendorEmail) {
+ try {
+ await sendEmail({
+ to: vendor.vendorEmail,
+ subject: `[SHI] 일반계약 조건검토 요청 - ${contract.contractNumber}`,
+ template: 'contract-review-request',
+ context: {
+ contractId: contractId,
+ contractNumber: contract.contractNumber,
+ contractName: contract.name,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/general-contract-review/${contractId}`,
+ language: 'ko',
+ },
+ })
+ } catch (emailError) {
+ console.error('이메일 발송 실패:', emailError)
+ // 이메일 발송 실패해도 계약 상태 변경은 유지
+ }
+ }
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '조건검토요청이 성공적으로 전송되었습니다.' }
+ } catch (error: any) {
+ console.error('조건검토요청 전송 오류:', error)
+ return {
+ success: false,
+ error: error.message || '조건검토요청 전송에 실패했습니다.'
+ }
+ }
+}
+
+// 조건검토요청 전송 (기존 함수 - 하위 호환성 유지)
+export async function requestContractReview(contractId: number, userId: number) {
+ try {
+ // 계약 정보 조회
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // 계약 상태를 'Request to Review'로 변경
+ await db
+ .update(generalContracts)
+ .set({
+ status: 'Request to Review',
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId,
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ // 협력업체 정보 조회
+ const [vendor] = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, contract.vendorId))
+ .limit(1)
+
+ // 협력업체 담당자에게 검토 요청 이메일 발송
+ if (vendor?.vendorEmail) {
+ try {
+ await sendEmail({
+ to: vendor.vendorEmail,
+ subject: `[SHI] 일반계약 조건검토 요청 - ${contract.contractNumber}`,
+ template: 'contract-review-request',
+ context: {
+ contractId: contractId,
+ contractNumber: contract.contractNumber,
+ contractName: contract.name,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/general-contract-review/${contractId}`,
+ language: 'ko',
+ },
+ })
+ } catch (emailError) {
+ console.error('이메일 발송 실패:', emailError)
+ // 이메일 발송 실패해도 계약 상태 변경은 유지
+ }
+ }
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '조건검토요청이 성공적으로 전송되었습니다.' }
+ } catch (error) {
+ console.error('조건검토요청 전송 오류:', error)
+ throw new Error('조건검토요청 전송에 실패했습니다.')
+ }
+}
+
+// 협력업체용 계약 정보 조회 (검토용 최소 정보)
+export async function getContractForVendorReview(contractId: number, vendorId?: number) {
+ try {
+ const contract = await db
+ .select({
+ id: generalContracts.id,
+ contractNumber: generalContracts.contractNumber,
+ revision: generalContracts.revision,
+ name: generalContracts.name,
+ status: generalContracts.status,
+ type: generalContracts.type,
+ category: generalContracts.category,
+ vendorId: generalContracts.vendorId,
+ contractAmount: generalContracts.contractAmount,
+ currency: generalContracts.currency,
+ startDate: generalContracts.startDate,
+ endDate: generalContracts.endDate,
+ specificationType: generalContracts.specificationType,
+ specificationManualText: generalContracts.specificationManualText,
+ contractScope: generalContracts.contractScope,
+ notes: generalContracts.notes,
+ vendorComment: generalContracts.vendorComment,
+ shiComment: generalContracts.shiComment,
+ })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract.length) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // 권한 확인: vendorId가 제공된 경우 해당 협력업체의 계약인지 확인
+ if (vendorId && contract[0].vendorId !== vendorId) {
+ throw new Error('이 계약에 대한 접근 권한이 없습니다.')
+ }
+
+ // 품목 정보 조회
+ const contractItems = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, contractId))
+
+ // 첨부파일 조회
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(eq(generalContractAttachments.contractId, contractId))
+
+ // 협력업체 정보 조회
+ const [vendor] = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, contract[0].vendorId))
+ .limit(1)
+
+ return {
+ ...contract[0],
+ contractItems,
+ attachments,
+ vendor: vendor || null,
+ }
+ } catch (error) {
+ console.error('협력업체용 계약 정보 조회 오류:', error)
+ throw error
+ }
+}
+
+// 협력업체 의견 회신
+export async function vendorReplyToContractReview(
+ contractId: number,
+ vendorComment: string,
+ vendorId: number
+) {
+ try {
+ // 계약 정보 조회 및 권한 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ if (contract.vendorId !== vendorId) {
+ throw new Error('이 계약에 대한 접근 권한이 없습니다.')
+ }
+
+ // 계약 상태 확인
+ if (contract.status !== 'Request to Review') {
+ throw new Error('조건검토요청 상태가 아닙니다.')
+ }
+
+ // 계약 상태를 'Vendor Replied Review'로 변경하고 vendorComment 저장
+ await db
+ .update(generalContracts)
+ .set({
+ status: 'Vendor Replied Review',
+ vendorComment: vendorComment,
+ lastUpdatedAt: new Date(),
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ // 당사 구매 담당자에게 회신 알림 이메일 발송
+ const [manager] = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, contract.registeredById))
+ .limit(1)
+
+ if (manager?.email) {
+ try {
+ await sendEmail({
+ to: manager.email,
+ subject: `[SHI] 협력업체 조건검토 회신 - ${contract.contractNumber}`,
+ template: 'vendor-review-reply',
+ context: {
+ contractId: contractId,
+ contractNumber: contract.contractNumber,
+ contractName: contract.name,
+ vendorName: contract.vendorName || '협력업체',
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/evcp/general-contracts/detail/${contractId}`,
+ language: 'ko',
+ },
+ })
+ } catch (emailError) {
+ console.error('이메일 발송 실패:', emailError)
+ }
+ }
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '의견이 성공적으로 회신되었습니다.' }
+ } catch (error) {
+ console.error('협력업체 의견 회신 오류:', error)
+ throw error
+ }
+}
+
+// 협력업체 의견 임시 저장
+export async function saveVendorCommentDraft(
+ contractId: number,
+ vendorComment: string,
+ vendorId: number
+) {
+ try {
+ // 계약 정보 조회 및 권한 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ if (contract.vendorId !== vendorId) {
+ throw new Error('이 계약에 대한 접근 권한이 없습니다.')
+ }
+
+ // 협력업체 의견을 임시 저장 (generalContracts 테이블의 vendorComment에 저장, 상태는 변경하지 않음)
+ await db
+ .update(generalContracts)
+ .set({
+ vendorComment: vendorComment,
+ lastUpdatedAt: new Date(),
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ return { success: true, message: '의견이 임시 저장되었습니다.' }
+ } catch (error) {
+ console.error('협력업체 의견 임시 저장 오류:', error)
+ throw error
+ }
+}
+
+// 당사 검토 확정
+export async function confirmContractReview(
+ contractId: number,
+ shiComment: string,
+ userId: number
+) {
+ try {
+ // 계약 정보 조회
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // 계약 상태 확인
+ if (contract.status !== 'Vendor Replied Review') {
+ throw new Error('협력업체 회신 상태가 아닙니다.')
+ }
+
+ // 계약 상태를 'SHI Confirmed Review'로 변경하고 shiComment 저장
+ await db
+ .update(generalContracts)
+ .set({
+ status: 'SHI Confirmed Review',
+ shiComment: shiComment,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId,
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '검토가 확정되었습니다.' }
+ } catch (error) {
+ console.error('당사 검토 확정 오류:', error)
+ throw error
+ }
+} \ No newline at end of file
diff --git a/lib/general-contracts/types.ts b/lib/general-contracts/types.ts
index 2b6731b6..33b1189f 100644
--- a/lib/general-contracts/types.ts
+++ b/lib/general-contracts/types.ts
@@ -17,15 +17,14 @@ export const GENERAL_CONTRACT_TYPES = [
'AL', // 연간운송계약
'OS', // 외주용역계약
'OW', // 도급계약
- 'IS', // 검사계약
'LO', // LOI (의향서)
'FA', // FA (Frame Agreement)
'SC', // 납품합의계약 (Supply Contract)
'OF', // 클레임상계계약 (Offset Agreement)
'AW', // 사전작업합의 (Advanced Work)
'AD', // 사전납품합의 (Advanced Delivery)
- 'AM', // 설계계약
- 'SC_SELL' // 폐기물매각계약 (Scrap) - 납품합의계약과 코드 중복으로 별도 명명
+ 'SG', // 임치(물품보관)계약
+ 'SR' // 폐기물매각계약 (Scrap)
] as const;
export type GeneralContractType = typeof GENERAL_CONTRACT_TYPES[number];
@@ -34,7 +33,8 @@ export type GeneralContractType = typeof GENERAL_CONTRACT_TYPES[number];
export const GENERAL_CONTRACT_STATUSES = [
'Draft', // 임시 저장
'Request to Review', // 조건검토요청
- 'Confirm to Review', // 조건검토완료
+ 'Vendor Replied Review', // 협력업체 회신
+ 'SHI Confirmed Review', // 당사 검토 확정
'Contract Accept Request', // 계약승인요청
'Complete the Contract', // 계약체결(승인)
'Reject to Accept Contract', // 계약승인거절
diff --git a/lib/general-contracts_old/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts_old/detail/general-contract-approval-request-dialog.tsx
new file mode 100644
index 00000000..f05fe9ef
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-approval-request-dialog.tsx
@@ -0,0 +1,1312 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { toast } from 'sonner'
+import {
+ FileText,
+ Upload,
+ Eye,
+ Send,
+ CheckCircle,
+ Download,
+ AlertCircle
+} from 'lucide-react'
+import { ContractDocuments } from './general-contract-documents'
+import { getActiveContractTemplates } from '@/lib/bidding/service'
+import { type BasicContractTemplate } from '@/db/schema'
+import {
+ getBasicInfo,
+ getContractItems,
+ getCommunicationChannel,
+ getLocation,
+ getFieldServiceRate,
+ getOffsetDetails,
+ getSubcontractChecklist,
+ uploadContractApprovalFile,
+ sendContractApprovalRequest
+} from '../service'
+
+interface ContractApprovalRequestDialogProps {
+ contract: Record<string, unknown>
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>
+ items: Record<string, unknown>[]
+ communicationChannel: Record<string, unknown> | null
+ location: Record<string, unknown> | null
+ fieldServiceRate: Record<string, unknown> | null
+ offsetDetails: Record<string, unknown> | null
+ subcontractChecklist: Record<string, unknown> | null
+}
+
+export function ContractApprovalRequestDialog({
+ contract,
+ open,
+ onOpenChange
+}: ContractApprovalRequestDialogProps) {
+ const { data: session } = useSession()
+ const [currentStep, setCurrentStep] = useState(1)
+ const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
+ const [uploadedFile, setUploadedFile] = useState<File | null>(null)
+ const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
+ const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null)
+ const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
+
+ // 기본계약 관련 상태
+ const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{
+ type: string;
+ templateName: string;
+ checked: boolean;
+ }>>([])
+ const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
+
+ const contractId = contract.id as number
+ const userId = session?.user?.id || ''
+
+ // LOI 템플릿용 변수 매핑 함수
+ const mapContractSummaryToLOITemplate = (contractSummary: ContractSummary) => {
+ const { basicInfo, items } = contractSummary
+ const firstItem = items && items.length > 0 ? items[0] : {}
+
+ // 날짜 포맷팅 헬퍼 함수
+ const formatDate = (date: any) => {
+ if (!date) return ''
+ try {
+ const d = new Date(date)
+ return d.toLocaleDateString('ko-KR')
+ } catch {
+ return ''
+ }
+ }
+
+ return {
+ // 날짜 관련 (템플릿에서 {{todayDate}} 형식으로 사용)
+ todayDate: new Date().toLocaleDateString('ko-KR'),
+
+ // 벤더 정보
+ vendorName: basicInfo?.vendorName || '',
+ representativeName: '', // 벤더 대표자 이름 - 현재 데이터에 없음, 향후 확장 가능
+
+ // 계약 기본 정보
+ contractNumber: basicInfo?.contractNumber || '',
+
+ // 프로젝트 정보
+ projectNumber: '', // 프로젝트 코드 - 현재 데이터에 없음, 향후 확장 가능
+ projectName: basicInfo?.projectName || '',
+ project: basicInfo?.projectName || '',
+
+ // 아이템 정보
+ item: firstItem?.itemInfo || '',
+
+ // 무역 조건
+ incoterms: basicInfo?.deliveryTerm || '', // Incoterms 대신 deliveryTerm 사용
+ shippingLocation: basicInfo?.shippingLocation || '',
+
+ // 금액 및 통화
+ contractCurrency: basicInfo?.currency || '',
+ contractAmount: basicInfo?.contractAmount || '',
+ totalAmount: basicInfo?.contractAmount || '', // totalAmount가 없으면 contractAmount 사용
+
+ // 수량
+ quantity: firstItem?.quantity || '',
+
+ // 납기일
+ contractDeliveryDate: formatDate(basicInfo?.contractDeliveryDate),
+
+ // 지급 조건
+ paymentTerm: basicInfo?.paymentTerm || '',
+
+ // 유효기간
+ validityEndDate: formatDate(basicInfo?.endDate), // validityEndDate 대신 endDate 사용
+ }
+ }
+
+ // 기본계약 생성 함수 (최종 전송 시점에 호출)
+ const generateBasicContractPdf = async (
+ vendorId: number,
+ contractType: string,
+ templateName: string
+ ): Promise<{ buffer: number[], fileName: string }> => {
+ try {
+ // 1. 템플릿 데이터 준비 (서버 액션 호출)
+ const prepareResponse = await fetch("/api/contracts/prepare-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ templateName,
+ vendorId,
+ }),
+ });
+
+ if (!prepareResponse.ok) {
+ const errorText = await prepareResponse.text();
+ throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
+ }
+
+ const { template, templateData } = await prepareResponse.json();
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ });
+
+ const templateBlob = await templateResponse.blob();
+ const templateFile = new window.File([templateBlob], "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ });
+
+ // 3. PDFTron WebViewer로 PDF 변환
+ const { default: WebViewer } = await import("@pdftron/webviewer");
+
+ const tempDiv = document.createElement('div');
+ tempDiv.style.display = 'none';
+ document.body.appendChild(tempDiv);
+
+ try {
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ enableOfficeEditing: true,
+ },
+ tempDiv
+ );
+
+ const { Core } = instance;
+ const { createDocument } = Core;
+
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ });
+
+ // 변수 치환 적용
+ await templateDoc.applyTemplateValues(templateData);
+ await new Promise(resolve => setTimeout(resolve, 3000));
+
+ const fileData = await templateDoc.getFileData();
+ const pdfBuffer = await Core.officeToPDFBuffer(fileData, { extension: 'docx' });
+
+ const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
+
+ instance.UI.dispose();
+ return {
+ buffer: Array.from(pdfBuffer),
+ fileName
+ };
+
+ } finally {
+ if (tempDiv.parentNode) {
+ document.body.removeChild(tempDiv);
+ }
+ }
+
+ } catch (error) {
+ console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
+ throw error;
+ }
+ };
+
+ // 기본계약 생성 및 선택 초기화
+ const initializeBasicContracts = React.useCallback(async () => {
+ if (!contractSummary?.basicInfo) return;
+
+ setIsLoadingBasicContracts(true);
+ try {
+ // 기본적으로 사용할 수 있는 계약서 타입들
+ const availableContracts: Array<{
+ type: string;
+ templateName: string;
+ checked: boolean;
+ }> = [
+ { type: "NDA", templateName: "비밀", checked: false },
+ { type: "General_GTC", templateName: "General GTC", checked: false },
+ { type: "기술자료", templateName: "기술", checked: false }
+ ];
+
+ // 프로젝트 코드가 있으면 Project GTC도 추가
+ if (contractSummary.basicInfo.projectCode) {
+ availableContracts.push({
+ type: "Project_GTC",
+ templateName: contractSummary.basicInfo.projectCode as string,
+ checked: false
+ });
+ }
+
+ setSelectedBasicContracts(availableContracts);
+ } catch (error) {
+ console.error('기본계약 초기화 실패:', error);
+ toast.error('기본계약 초기화에 실패했습니다.');
+ } finally {
+ setIsLoadingBasicContracts(false);
+ }
+ }, [contractSummary]);
+
+ // 기본계약 선택 토글
+ const toggleBasicContract = (type: string) => {
+ setSelectedBasicContracts(prev =>
+ prev.map(contract =>
+ contract.type === type
+ ? { ...contract, checked: !contract.checked }
+ : contract
+ )
+ );
+ };
+
+
+ // 1단계: 계약 현황 수집
+ const collectContractSummary = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ // 각 컴포넌트에서 활성화된 데이터만 수집
+ const summary: ContractSummary = {
+ basicInfo: {},
+ items: [],
+ communicationChannel: null,
+ location: null,
+ fieldServiceRate: null,
+ offsetDetails: null,
+ subcontractChecklist: null
+ }
+
+ // Basic Info 확인 (항상 활성화)
+ try {
+ const basicInfoData = await getBasicInfo(contractId)
+ if (basicInfoData && basicInfoData.success) {
+ summary.basicInfo = basicInfoData.data || {}
+ }
+ } catch {
+ console.log('Basic Info 데이터 없음')
+ }
+
+ // 품목 정보 확인
+ try {
+ const itemsData = await getContractItems(contractId)
+ if (itemsData && itemsData.length > 0) {
+ summary.items = itemsData
+ }
+ } catch {
+ console.log('품목 정보 데이터 없음')
+ }
+
+ // 각 컴포넌트의 활성화 상태 및 데이터 확인
+ try {
+ // Communication Channel 확인
+ const commData = await getCommunicationChannel(contractId)
+ if (commData && commData.enabled) {
+ summary.communicationChannel = commData
+ }
+ } catch {
+ console.log('Communication Channel 데이터 없음')
+ }
+
+ try {
+ // Location 확인
+ const locationData = await getLocation(contractId)
+ if (locationData && locationData.enabled) {
+ summary.location = locationData
+ }
+ } catch {
+ console.log('Location 데이터 없음')
+ }
+
+ try {
+ // Field Service Rate 확인
+ const fieldServiceData = await getFieldServiceRate(contractId)
+ if (fieldServiceData && fieldServiceData.enabled) {
+ summary.fieldServiceRate = fieldServiceData
+ }
+ } catch {
+ console.log('Field Service Rate 데이터 없음')
+ }
+
+ try {
+ // Offset Details 확인
+ const offsetData = await getOffsetDetails(contractId)
+ if (offsetData && offsetData.enabled) {
+ summary.offsetDetails = offsetData
+ }
+ } catch {
+ console.log('Offset Details 데이터 없음')
+ }
+
+ try {
+ // Subcontract Checklist 확인
+ const subcontractData = await getSubcontractChecklist(contractId)
+ if (subcontractData && subcontractData.success && subcontractData.enabled) {
+ summary.subcontractChecklist = subcontractData.data
+ }
+ } catch {
+ console.log('Subcontract Checklist 데이터 없음')
+ }
+
+ console.log('contractSummary 구조:', summary)
+ console.log('basicInfo 내용:', summary.basicInfo)
+ setContractSummary(summary)
+ } catch (error) {
+ console.error('Error collecting contract summary:', error)
+ toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [contractId])
+
+ // 3단계: 파일 업로드 처리
+ const handleFileUpload = async (file: File) => {
+ // 파일 확장자 검증
+ const allowedExtensions = ['.doc', '.docx']
+ const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
+
+ if (!allowedExtensions.includes(fileExtension)) {
+ toast.error('Word 문서(.doc, .docx) 파일만 업로드 가능합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 서버액션을 사용하여 파일 저장 (본 계약문서로 고정)
+ const result = await uploadContractApprovalFile(
+ contractId,
+ file,
+ userId
+ )
+
+ if (result.success) {
+ setUploadedFile(file)
+ toast.success('파일이 업로드되었습니다.')
+ } else {
+ throw new Error(result.error || '파일 업로드 실패')
+ }
+ } catch (error) {
+ console.error('Error uploading file:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 4단계: PDF 생성 및 미리보기 (PDFTron 사용)
+ const generatePdf = async () => {
+ if (!uploadedFile || !contractSummary) {
+ toast.error('업로드된 파일과 계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
+ const tempDiv = document.createElement('div')
+ tempDiv.style.display = 'none'
+ document.body.appendChild(tempDiv)
+
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ )
+
+ try {
+ const { Core } = instance
+ const { createDocument } = Core
+
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(uploadedFile, {
+ filename: uploadedFile.name,
+ extension: 'docx',
+ })
+
+ // LOI 템플릿용 변수 매핑
+ const mappedTemplateData = mapContractSummaryToLOITemplate(contractSummary)
+
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as any)
+ console.log("✅ 변수 치환 완료")
+
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
+
+ console.log(`✅ PDF 변환 완료: ${uploadedFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+
+ // PDF 버퍼를 Blob URL로 변환하여 미리보기
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ setGeneratedPdfUrl(pdfUrl)
+
+ // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
+ setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
+
+ toast.success('PDF가 생성되었습니다.')
+
+ } finally {
+ // 임시 WebViewer 정리
+ instance.UI.dispose()
+ document.body.removeChild(tempDiv)
+ }
+
+ } catch (error) {
+ console.error('❌ PDF 생성 실패:', error)
+ toast.error('PDF 생성 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 미리보기 기능
+ const openPdfPreview = async () => {
+ if (!generatedPdfBuffer) {
+ toast.error('생성된 PDF가 없습니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 기존 인스턴스가 있다면 정리
+ if (pdfViewerInstance) {
+ console.log("🔄 기존 WebViewer 인스턴스 정리")
+ try {
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('기존 WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 미리보기용 컨테이너 확인
+ let previewDiv = document.getElementById('pdf-preview-container')
+ if (!previewDiv) {
+ console.log("🔄 컨테이너 생성")
+ previewDiv = document.createElement('div')
+ previewDiv.id = 'pdf-preview-container'
+ previewDiv.className = 'w-full h-full'
+ previewDiv.style.width = '100%'
+ previewDiv.style.height = '100%'
+
+ // 실제 컨테이너에 추가
+ const actualContainer = document.querySelector('[data-pdf-container]')
+ if (actualContainer) {
+ actualContainer.appendChild(previewDiv)
+ }
+ }
+
+ console.log("🔄 WebViewer 인스턴스 생성 시작")
+
+ // WebViewer 인스턴스 생성 (문서 없이)
+ const instance = await Promise.race([
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ previewDiv
+ ),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
+ )
+ ])
+
+ console.log("🔄 WebViewer 인스턴스 생성 완료")
+ setPdfViewerInstance(instance)
+
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ console.log("🔄 PDF Blob URL 생성:", pdfUrl)
+
+ // 문서 로드
+ console.log("🔄 문서 로드 시작")
+ const { documentViewer } = instance.Core
+
+ // 문서 로드 이벤트 대기
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('문서 로드 타임아웃'))
+ }, 20000)
+
+ const onDocumentLoaded = () => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.log("🔄 문서 로드 완료")
+ resolve(true)
+ }
+
+ const onDocumentError = (error: any) => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.error('문서 로드 오류:', error)
+ reject(error)
+ }
+
+ documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.addEventListener('documentError', onDocumentError)
+
+ // 문서 로드 시작
+ documentViewer.loadDocument(pdfUrl)
+ })
+
+ setIsPdfPreviewVisible(true)
+ toast.success('PDF 미리보기가 준비되었습니다.')
+
+ } catch (error) {
+ console.error('PDF 미리보기 실패:', error)
+ toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 다운로드 기능
+ const downloadPdf = () => {
+ if (!generatedPdfBuffer) {
+ toast.error('다운로드할 PDF가 없습니다.')
+ return
+ }
+
+ const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+
+ const link = document.createElement('a')
+ link.href = pdfUrl
+ link.download = `contract_${contractId}_${Date.now()}.pdf`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+
+ URL.revokeObjectURL(pdfUrl)
+ toast.success('PDF가 다운로드되었습니다.')
+ }
+
+ // PDF 미리보기 닫기
+ const closePdfPreview = () => {
+ console.log("🔄 PDF 미리보기 닫기 시작")
+ if (pdfViewerInstance) {
+ try {
+ console.log("🔄 WebViewer 인스턴스 정리")
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 컨테이너 정리
+ const previewDiv = document.getElementById('pdf-preview-container')
+ if (previewDiv) {
+ try {
+ previewDiv.innerHTML = ''
+ } catch (error) {
+ console.warn('컨테이너 정리 중 오류:', error)
+ }
+ }
+
+ setIsPdfPreviewVisible(false)
+ console.log("🔄 PDF 미리보기 닫기 완료")
+ }
+
+ // 최종 전송
+ const handleFinalSubmit = async () => {
+ if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
+ toast.error('생성된 PDF가 필요합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 기본계약서 생성 (최종 전송 시점에)
+ let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
+
+ const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
+ if (contractsToGenerate.length > 0) {
+ // vendorId 조회
+ let vendorId: number | undefined;
+ try {
+ const basicInfoData = await getBasicInfo(contractId);
+ if (basicInfoData && basicInfoData.success && basicInfoData.data) {
+ vendorId = basicInfoData.data.vendorId;
+ }
+ } catch (error) {
+ console.error('vendorId 조회 실패:', error);
+ }
+
+ if (vendorId) {
+ toast.info('기본계약서를 생성하는 중입니다...');
+
+ for (const contract of contractsToGenerate) {
+ try {
+ const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
+ generatedBasicContractPdfs.push({
+ key: `${vendorId}_${contract.type}_${contract.templateName}`,
+ ...pdf
+ });
+ } catch (error) {
+ console.error(`${contract.type} 계약서 생성 실패:`, error);
+ // 개별 실패는 전체를 중단하지 않음
+ }
+ }
+
+ if (generatedBasicContractPdfs.length > 0) {
+ toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
+ }
+ }
+ }
+
+ // 서버액션을 사용하여 계약승인요청 전송
+ const result = await sendContractApprovalRequest(
+ contractSummary,
+ generatedPdfBuffer,
+ 'contractDocument',
+ userId,
+ generatedBasicContractPdfs
+ )
+
+ if (result.success) {
+ toast.success('계약승인요청이 전송되었습니다.')
+ onOpenChange(false)
+ } else {
+ // 서버에서 이미 처리된 에러 메시지 표시
+ toast.error(result.error || '계약승인요청 전송 실패')
+ return
+ }
+ } catch (error: any) {
+ console.error('Error submitting approval request:', error)
+
+ // 데이터베이스 중복 키 오류 처리
+ if (error.message && error.message.includes('duplicate key value violates unique constraint')) {
+ toast.error('이미 존재하는 계약번호입니다. 다른 계약번호를 사용해주세요.')
+ return
+ }
+
+ // 다른 오류에 대한 일반적인 처리
+ toast.error('계약승인요청 전송 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 다이얼로그가 열릴 때 1단계 데이터 수집
+ useEffect(() => {
+ if (open && currentStep === 1) {
+ collectContractSummary()
+ }
+ }, [open, currentStep, collectContractSummary])
+
+ // 계약 요약이 준비되면 기본계약 초기화
+ useEffect(() => {
+ if (contractSummary && currentStep === 2) {
+ const loadBasicContracts = async () => {
+ await initializeBasicContracts()
+ }
+ loadBasicContracts()
+ }
+ }, [contractSummary, currentStep, initializeBasicContracts])
+
+ // 다이얼로그가 닫힐 때 PDF 뷰어 정리
+ useEffect(() => {
+ if (!open) {
+ closePdfPreview()
+ }
+ }, [open])
+
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 계약승인요청
+ </DialogTitle>
+ </DialogHeader>
+
+ <Tabs value={currentStep.toString()} className="w-full">
+ <TabsList className="grid w-full grid-cols-4">
+ <TabsTrigger value="1" disabled={currentStep < 1}>
+ 1. 계약 현황 정리
+ </TabsTrigger>
+ <TabsTrigger value="2" disabled={currentStep < 2}>
+ 2. 기본계약 체크
+ </TabsTrigger>
+ <TabsTrigger value="3" disabled={currentStep < 3}>
+ 3. 문서 업로드
+ </TabsTrigger>
+ <TabsTrigger value="4" disabled={currentStep < 4}>
+ 4. PDF 미리보기
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 1단계: 계약 현황 정리 */}
+ <TabsContent value="1" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <CheckCircle className="h-5 w-5 text-green-600" />
+ 작성된 계약 현황
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoading ? (
+ <div className="text-center py-4">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 기본 정보 (필수) */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">기본 정보</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
+ </div>
+ <div>
+ <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
+ </div>
+ <div>
+ <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
+ </div>
+ <div>
+ <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
+ </div>
+ </div>
+ </div>
+
+ {/* 지급/인도 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">지급/인도 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
+ </div>
+ <div>
+ <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
+ </div>
+ </div>
+ </div>
+
+ {/* 추가 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">추가 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약성립조건:</span>
+ {contractSummary?.basicInfo?.contractEstablishmentConditions &&
+ Object.entries(contractSummary.basicInfo.contractEstablishmentConditions)
+ .filter(([, value]) => value === true)
+ .map(([key]) => key)
+ .join(', ') || '없음'}
+ </div>
+ <div>
+ <span className="font-medium">계약해지조건:</span>
+ {contractSummary?.basicInfo?.contractTerminationConditions &&
+ Object.entries(contractSummary.basicInfo.contractTerminationConditions)
+ .filter(([, value]) => value === true)
+ .map(([key]) => key)
+ .join(', ') || '없음'}
+ </div>
+ </div>
+ </div>
+
+ {/* 품목 정보 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="items-enabled"
+ checked={contractSummary?.items && contractSummary.items.length > 0}
+ disabled
+ />
+ <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ {contractSummary?.items && contractSummary.items.length > 0 ? (
+ <div className="space-y-2">
+ <p className="text-sm text-muted-foreground">
+ 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
+ </p>
+ <div className="max-h-32 overflow-y-auto">
+ {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
+ <div key={index} className="text-xs bg-gray-50 p-2 rounded">
+ <div className="font-medium">{item.itemInfo || item.description || `품목 ${index + 1}`}</div>
+ <div className="text-muted-foreground">
+ 수량: {item.quantity || 0} | 단가: {item.contractUnitPrice || item.unitPrice || 0}
+ </div>
+ </div>
+ ))}
+ {contractSummary.items.length > 3 && (
+ <div className="text-xs text-muted-foreground text-center">
+ ... 외 {contractSummary.items.length - 3}개 품목
+ </div>
+ )}
+ </div>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ 품목 정보가 입력되지 않았습니다.
+ </p>
+ )}
+ </div>
+
+ {/* 커뮤니케이션 채널 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="communication-enabled"
+ checked={!!contractSummary?.communicationChannel}
+ disabled
+ />
+ <Label htmlFor="communication-enabled" className="font-medium">
+ 커뮤니케이션 채널
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.communicationChannel
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+
+ {/* 위치 정보 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="location-enabled"
+ checked={!!contractSummary?.location}
+ disabled
+ />
+ <Label htmlFor="location-enabled" className="font-medium">
+ 위치 정보
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.location
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+
+ {/* 현장 서비스 요율 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="fieldService-enabled"
+ checked={!!contractSummary?.fieldServiceRate}
+ disabled
+ />
+ <Label htmlFor="fieldService-enabled" className="font-medium">
+ 현장 서비스 요율
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.fieldServiceRate
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+
+ {/* 오프셋 세부사항 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="offset-enabled"
+ checked={!!contractSummary?.offsetDetails}
+ disabled
+ />
+ <Label htmlFor="offset-enabled" className="font-medium">
+ 오프셋 세부사항
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.offsetDetails
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+
+ {/* 하도급 체크리스트 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="subcontract-enabled"
+ checked={!!contractSummary?.subcontractChecklist}
+ disabled
+ />
+ <Label htmlFor="subcontract-enabled" className="font-medium">
+ 하도급 체크리스트
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.subcontractChecklist
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-end">
+ <Button
+ onClick={() => setCurrentStep(2)}
+ disabled={isLoading}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 2단계: 기본계약 체크 */}
+ <TabsContent value="2" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5 text-blue-600" />
+ 기본계약서 선택
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoadingBasicContracts ? (
+ <div className="text-center py-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {selectedBasicContracts.length > 0 ? (
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="font-medium">필요한 기본계약서</h4>
+ <Badge variant="outline">
+ {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
+ </Badge>
+ </div>
+
+ <div className="grid gap-3">
+ {selectedBasicContracts.map((contract) => (
+ <div
+ key={contract.type}
+ className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ id={`contract-${contract.type}`}
+ checked={contract.checked}
+ onCheckedChange={() => toggleBasicContract(contract.type)}
+ />
+ <div>
+ <Label
+ htmlFor={`contract-${contract.type}`}
+ className="font-medium cursor-pointer"
+ >
+ {contract.type}
+ </Label>
+ <p className="text-sm text-muted-foreground">
+ 템플릿: {contract.templateName}
+ </p>
+ </div>
+ </div>
+ <Badge
+ variant="secondary"
+ className="text-xs"
+ >
+ {contract.checked ? "선택됨" : "미선택"}
+ </Badge>
+ </div>
+ ))}
+ </div>
+
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>기본계약서 목록을 불러올 수 없습니다.</p>
+ <p className="text-sm">잠시 후 다시 시도해주세요.</p>
+ </div>
+ )}
+
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(1)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={() => setCurrentStep(3)}
+ disabled={isLoadingBasicContracts}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 3단계: 문서 업로드 */}
+ <TabsContent value="3" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5 text-blue-600" />
+ 계약서 업로드
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="space-y-4">
+ <p className="text-lg text-muted-foreground">일반계약 표준문서 관리 페이지에 접속하여, 원하는 양식의 계약서를 다운받아 수정 후 업로드하세요.</p>
+ <div>
+ <Label htmlFor="file-upload">파일 업로드</Label>
+ <Input
+ id="file-upload"
+ type="file"
+ accept=".doc,.docx"
+ onChange={(e) => {
+ const file = e.target.files?.[0]
+ if (file) handleFileUpload(file)
+ }}
+ />
+ <p className="text-sm text-muted-foreground mt-1">
+ Word 문서(.doc, .docx) 파일만 업로드 가능합니다.
+ </p>
+ </div>
+
+ {/* ContractDocuments 컴포넌트 사용 */}
+ {/* <div className="mt-4">
+ <Label>업로드된 문서</Label>
+ <ContractDocuments
+ contractId={contractId}
+ userId={userId}
+ readOnly={false}
+ />
+ </div> */}
+
+ {uploadedFile && (
+ <div className="border rounded-lg p-4 bg-green-50">
+ <div className="flex items-center gap-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900">업로드 완료</span>
+ </div>
+ <p className="text-sm text-green-800 mt-1">{uploadedFile.name}</p>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(2)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={() => setCurrentStep(4)}
+ disabled={!uploadedFile}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 4단계: PDF 미리보기 */}
+ <TabsContent value="4" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5 text-purple-600" />
+ PDF 미리보기
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {!generatedPdfUrl ? (
+ <div className="text-center py-8">
+ <Button onClick={generatePdf} disabled={isLoading}>
+ {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
+ </Button>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="border rounded-lg p-4 bg-green-50">
+ <div className="flex items-center gap-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900">PDF 생성 완료</span>
+ </div>
+ </div>
+
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-4">
+ <h4 className="font-medium">생성된 PDF</h4>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={downloadPdf}
+ disabled={isLoading}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openPdfPreview}
+ disabled={isLoading}
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ 미리보기
+ </Button>
+ </div>
+ </div>
+
+ {/* PDF 미리보기 영역 */}
+ <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container>
+ {isPdfPreviewVisible ? (
+ <>
+ <div className="absolute top-2 right-2 z-10">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={closePdfPreview}
+ className="bg-white/90 hover:bg-white"
+ >
+ ✕ 닫기
+ </Button>
+ </div>
+ <div id="pdf-preview-container" className="w-full h-full" />
+ </>
+ ) : (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-2" />
+ <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(3)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={handleFinalSubmit}
+ disabled={!generatedPdfUrl || isLoading}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ <Send className="h-4 w-4 mr-2" />
+ {isLoading ? '전송 중...' : '최종 전송'}
+ </Button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ )} \ No newline at end of file
diff --git a/lib/general-contracts_old/detail/general-contract-basic-info.tsx b/lib/general-contracts_old/detail/general-contract-basic-info.tsx
new file mode 100644
index 00000000..d891fe63
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-basic-info.tsx
@@ -0,0 +1,1250 @@
+'use client'
+
+import React, { useState } from 'react'
+import { useSession } from 'next-auth/react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import { Button } from '@/components/ui/button'
+import { Save, LoaderIcon } from 'lucide-react'
+import { updateContractBasicInfo, getContractBasicInfo } from '../service'
+import { toast } from 'sonner'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { GeneralContract } from '@/db/schema'
+import { ContractDocuments } from './general-contract-documents'
+import { getPaymentTermsForSelection, getIncotermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS, getTaxConditionName } from '@/lib/tax-conditions/types'
+
+interface ContractBasicInfoProps {
+ contractId: number
+}
+
+export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
+ const session = useSession()
+ const [isLoading, setIsLoading] = useState(false)
+ const [contract, setContract] = useState<GeneralContract | null>(null)
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+
+ // 독립적인 상태 관리
+ const [paymentDeliveryPercent, setPaymentDeliveryPercent] = useState('')
+
+ // Procurement 데이터 상태들
+ const [paymentTermsOptions, setPaymentTermsOptions] = useState<Array<{code: string, description: string}>>([])
+ const [incotermsOptions, setIncotermsOptions] = useState<Array<{code: string, description: string}>>([])
+ const [shippingPlaces, setShippingPlaces] = useState<Array<{code: string, description: string}>>([])
+ const [destinationPlaces, setDestinationPlaces] = useState<Array<{code: string, description: string}>>([])
+ const [procurementLoading, setProcurementLoading] = useState(false)
+
+ const [formData, setFormData] = useState({
+ specificationType: '',
+ specificationManualText: '',
+ unitPriceType: '',
+ warrantyPeriod: {
+ 납품후: { enabled: false, period: 0, maxPeriod: 0 },
+ 인도후: { enabled: false, period: 0, maxPeriod: 0 },
+ 작업후: { enabled: false, period: 0, maxPeriod: 0 },
+ 기타: { enabled: false, period: 0, maxPeriod: 0 },
+ },
+ contractAmount: null as number | null,
+ currency: 'KRW',
+ linkedPoNumber: '',
+ linkedBidNumber: '',
+ notes: '',
+ // 개별 JSON 필드들 (스키마에 맞게)
+ paymentBeforeDelivery: {} as any,
+ paymentDelivery: '', // varchar 타입
+ paymentAfterDelivery: {} as any,
+ paymentTerm: '',
+ taxType: '',
+ liquidatedDamages: false as boolean,
+ liquidatedDamagesPercent: '',
+ deliveryType: '',
+ deliveryTerm: '',
+ shippingLocation: '',
+ dischargeLocation: '',
+ contractDeliveryDate: '',
+ contractEstablishmentConditions: {
+ regularVendorRegistration: false,
+ projectAward: false,
+ ownerApproval: false,
+ other: false,
+ },
+ interlockingSystem: '',
+ mandatoryDocuments: {
+ technicalDataAgreement: false,
+ nda: false,
+ basicCompliance: false,
+ safetyHealthAgreement: false,
+ },
+ contractTerminationConditions: {
+ standardTermination: false,
+ projectNotAwarded: false,
+ other: false,
+ },
+ })
+
+ const [errors] = useState<Record<string, string>>({})
+
+ // 계약 데이터 로드
+ React.useEffect(() => {
+ const loadContract = async () => {
+ try {
+ console.log('Loading contract with ID:', contractId)
+ const contractData = await getContractBasicInfo(contractId)
+ console.log('Contract data received:', contractData)
+ setContract(contractData as GeneralContract)
+
+ // JSON 필드들 파싱 (null 체크) - 스키마에 맞게 개별 필드로 접근
+ const paymentBeforeDelivery = (contractData?.paymentBeforeDelivery && typeof contractData.paymentBeforeDelivery === 'object') ? contractData.paymentBeforeDelivery as any : {}
+ const paymentAfterDelivery = (contractData?.paymentAfterDelivery && typeof contractData.paymentAfterDelivery === 'object') ? contractData.paymentAfterDelivery as any : {}
+ const warrantyPeriod = (contractData?.warrantyPeriod && typeof contractData.warrantyPeriod === 'object') ? contractData.warrantyPeriod as any : {}
+ const contractEstablishmentConditions = (contractData?.contractEstablishmentConditions && typeof contractData.contractEstablishmentConditions === 'object') ? contractData.contractEstablishmentConditions as any : {}
+ const mandatoryDocuments = (contractData?.mandatoryDocuments && typeof contractData.mandatoryDocuments === 'object') ? contractData.mandatoryDocuments as any : {}
+ const contractTerminationConditions = (contractData?.contractTerminationConditions && typeof contractData.contractTerminationConditions === 'object') ? contractData.contractTerminationConditions as any : {}
+
+ // paymentDelivery에서 퍼센트와 타입 분리
+ const paymentDeliveryValue = contractData?.paymentDelivery || ''
+ let paymentDeliveryType = ''
+ let paymentDeliveryPercentValue = ''
+
+ if (paymentDeliveryValue.includes('%')) {
+ const match = paymentDeliveryValue.match(/(\d+)%\s*(.+)/)
+ if (match) {
+ paymentDeliveryPercentValue = match[1]
+ paymentDeliveryType = match[2]
+ }
+ } else {
+ paymentDeliveryType = paymentDeliveryValue
+ }
+
+ setPaymentDeliveryPercent(paymentDeliveryPercentValue)
+
+ setFormData({
+ specificationType: contractData?.specificationType || '',
+ specificationManualText: contractData?.specificationManualText || '',
+ unitPriceType: contractData?.unitPriceType || '',
+ warrantyPeriod: warrantyPeriod || {
+ 납품후: { enabled: false, period: 0, maxPeriod: 0 },
+ 인도후: { enabled: false, period: 0, maxPeriod: 0 },
+ 작업후: { enabled: false, period: 0, maxPeriod: 0 },
+ 기타: { enabled: false, period: 0, maxPeriod: 0 },
+ },
+ contractAmount: contractData?.contractAmount || null,
+ currency: contractData?.currency || 'KRW',
+ linkedPoNumber: contractData?.linkedPoNumber || '',
+ linkedBidNumber: contractData?.linkedBidNumber || '',
+ notes: contractData?.notes || '',
+ // 개별 JSON 필드들
+ paymentBeforeDelivery: paymentBeforeDelivery || {} as any,
+ paymentDelivery: paymentDeliveryType, // 분리된 타입만 저장
+ paymentAfterDelivery: paymentAfterDelivery || {} as any,
+ paymentTerm: contractData?.paymentTerm || '',
+ taxType: contractData?.taxType || '',
+ liquidatedDamages: Boolean(contractData?.liquidatedDamages),
+ liquidatedDamagesPercent: contractData?.liquidatedDamagesPercent || '',
+ deliveryType: contractData?.deliveryType || '',
+ deliveryTerm: contractData?.deliveryTerm || '',
+ shippingLocation: contractData?.shippingLocation || '',
+ dischargeLocation: contractData?.dischargeLocation || '',
+ contractDeliveryDate: contractData?.contractDeliveryDate || '',
+ contractEstablishmentConditions: contractEstablishmentConditions || {
+ regularVendorRegistration: false,
+ projectAward: false,
+ ownerApproval: false,
+ other: false,
+ },
+ interlockingSystem: contractData?.interlockingSystem || '',
+ mandatoryDocuments: mandatoryDocuments || {
+ technicalDataAgreement: false,
+ nda: false,
+ basicCompliance: false,
+ safetyHealthAgreement: false,
+ },
+ contractTerminationConditions: contractTerminationConditions || {
+ standardTermination: false,
+ projectNotAwarded: false,
+ other: false,
+ },
+ })
+ } catch (error) {
+ console.error('Error loading contract:', error)
+ toast.error('계약 정보를 불러오는 중 오류가 발생했습니다.')
+ }
+ }
+
+ if (contractId) {
+ loadContract()
+ }
+ }, [contractId])
+
+ // Procurement 데이터 로드 함수들
+ const loadPaymentTerms = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getPaymentTermsForSelection();
+ setPaymentTermsOptions(data);
+ } catch (error) {
+ console.error("Failed to load payment terms:", error);
+ toast.error("결제조건 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ const loadIncoterms = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getIncotermsForSelection();
+ setIncotermsOptions(data);
+ } catch (error) {
+ console.error("Failed to load incoterms:", error);
+ toast.error("운송조건 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ const loadShippingPlaces = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getPlaceOfShippingForSelection();
+ setShippingPlaces(data);
+ } catch (error) {
+ console.error("Failed to load shipping places:", error);
+ toast.error("선적지 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getPlaceOfDestinationForSelection();
+ setDestinationPlaces(data);
+ } catch (error) {
+ console.error("Failed to load destination places:", error);
+ toast.error("하역지 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ // 컴포넌트 마운트 시 procurement 데이터 로드
+ React.useEffect(() => {
+ loadPaymentTerms();
+ loadIncoterms();
+ loadShippingPlaces();
+ loadDestinationPlaces();
+ }, [loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]);
+ const handleSaveContractInfo = async () => {
+ if (!userId) {
+ toast.error('사용자 정보를 찾을 수 없습니다.')
+ return
+ }
+ try {
+ setIsLoading(true)
+
+ // 필수값 validation 체크
+ const validationErrors: string[] = []
+ if (!formData.specificationType) validationErrors.push('사양')
+ if (!formData.paymentDelivery) validationErrors.push('납품 지급조건')
+ if (!formData.currency) validationErrors.push('계약통화')
+ if (!formData.paymentTerm) validationErrors.push('지불조건')
+ if (!formData.taxType) validationErrors.push('세금조건')
+
+ if (validationErrors.length > 0) {
+ toast.error(`다음 필수 항목을 입력해주세요: ${validationErrors.join(', ')}`)
+ return
+ }
+
+ // paymentDelivery와 paymentDeliveryPercent 합쳐서 저장
+ const dataToSave = {
+ ...formData,
+ paymentDelivery: (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && paymentDeliveryPercent
+ ? `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
+ : formData.paymentDelivery
+ }
+
+ await updateContractBasicInfo(contractId, dataToSave, userId as number)
+ toast.success('계약 정보가 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving contract info:', error)
+ toast.error('계약 정보 저장 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Card className="w-full">
+ <CardHeader>
+ <CardTitle>계약 기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Tabs defaultValue="basic" className="w-full">
+ <TabsList className="grid w-full grid-cols-4 h-auto overflow-x-auto">
+ <TabsTrigger value="basic" className="text-xs px-2 py-2 whitespace-nowrap">기본 정보</TabsTrigger>
+ <TabsTrigger value="conditions" className="text-xs px-2 py-2 whitespace-nowrap">지급/인도 조건</TabsTrigger>
+ <TabsTrigger value="additional" className="text-xs px-2 py-2 whitespace-nowrap">추가 조건</TabsTrigger>
+ <TabsTrigger value="documents" className="text-xs px-2 py-2 whitespace-nowrap">계약첨부문서</TabsTrigger>
+ </TabsList>
+
+ {/* 기본 정보 탭 */}
+ <TabsContent value="basic" className="space-y-6">
+ <Card>
+ {/* 보증기간 및 단가유형 */}
+ <CardHeader>
+ <CardTitle>보증기간 및 단가유형</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 3그리드: 보증기간, 사양, 단가 */}
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ {/* 보증기간 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="warrantyPeriod">품질/하자 보증기간</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="warrantyAfterDelivery"
+ checked={formData.warrantyPeriod.납품후?.enabled || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 납품후: {
+ ...prev.warrantyPeriod.납품후,
+ enabled: e.target.checked
+ }
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="warrantyAfterDelivery" className="text-sm">납품 후</Label>
+ </div>
+ {formData.warrantyPeriod.납품후?.enabled && (
+ <div className="ml-6 flex items-center space-x-2">
+ <Input
+ type="number"
+ placeholder="보증기간"
+ value={formData.warrantyPeriod.납품후?.period || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 납품후: {
+ ...prev.warrantyPeriod.납품후,
+ period: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월, 최대</span>
+ <Input
+ type="number"
+ placeholder="최대"
+ value={formData.warrantyPeriod.납품후?.maxPeriod || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 납품후: {
+ ...prev.warrantyPeriod.납품후,
+ maxPeriod: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월</span>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="warrantyAfterHandover"
+ checked={formData.warrantyPeriod.인도후?.enabled || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 인도후: {
+ ...prev.warrantyPeriod.인도후,
+ enabled: e.target.checked
+ }
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="warrantyAfterHandover" className="text-sm">인도 후</Label>
+ </div>
+ {formData.warrantyPeriod.인도후?.enabled && (
+ <div className="ml-6 flex items-center space-x-2">
+ <Input
+ type="number"
+ placeholder="보증기간"
+ value={formData.warrantyPeriod.인도후?.period || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 인도후: {
+ ...prev.warrantyPeriod.인도후,
+ period: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월, 최대</span>
+ <Input
+ type="number"
+ placeholder="최대"
+ value={formData.warrantyPeriod.인도후?.maxPeriod || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 인도후: {
+ ...prev.warrantyPeriod.인도후,
+ maxPeriod: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월</span>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="warrantyAfterWork"
+ checked={formData.warrantyPeriod.작업후?.enabled || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 작업후: {
+ ...prev.warrantyPeriod.작업후,
+ enabled: e.target.checked
+ }
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="warrantyAfterWork" className="text-sm">작업 후</Label>
+ </div>
+ {formData.warrantyPeriod.작업후?.enabled && (
+ <div className="ml-6 flex items-center space-x-2">
+ <Input
+ type="number"
+ placeholder="보증기간"
+ value={formData.warrantyPeriod.작업후?.period || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 작업후: {
+ ...prev.warrantyPeriod.작업후,
+ period: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월, 최대</span>
+ <Input
+ type="number"
+ placeholder="최대"
+ value={formData.warrantyPeriod.작업후?.maxPeriod || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 작업후: {
+ ...prev.warrantyPeriod.작업후,
+ maxPeriod: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월</span>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="warrantyOther"
+ checked={formData.warrantyPeriod.기타?.enabled || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 기타: {
+ ...prev.warrantyPeriod.기타,
+ enabled: e.target.checked
+ }
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="warrantyOther" className="text-sm">기타/미적용</Label>
+ </div>
+ </div>
+ </div>
+ {/* 사양 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="specificationType">사양 <span className="text-red-600">*</span></Label>
+ <Select value={formData.specificationType} onValueChange={(value) => setFormData(prev => ({ ...prev, specificationType: value }))}>
+ <SelectTrigger className={errors.specificationType ? 'border-red-500' : ''}>
+ <SelectValue placeholder="사양을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="첨부파일">첨부파일</SelectItem>
+ <SelectItem value="표준사양">표준사양</SelectItem>
+ <SelectItem value="수기사양">수기사양</SelectItem>
+ </SelectContent>
+ </Select>
+ {errors.specificationType && (
+ <p className="text-sm text-red-600">사양은 필수값입니다.</p>
+ )}
+ </div>
+ {/* 단가 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="unitPriceType">단가 유형</Label>
+ <Select value={formData.unitPriceType} onValueChange={(value) => setFormData(prev => ({ ...prev, unitPriceType: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="단가 유형을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="자재개별단가">자재개별단가</SelectItem>
+ <SelectItem value="서비스용역단가">서비스용역단가</SelectItem>
+ <SelectItem value="프로젝트단가">프로젝트단가</SelectItem>
+ <SelectItem value="지역별단가">지역별단가</SelectItem>
+ <SelectItem value="직무직급단가">직무직급단가</SelectItem>
+ <SelectItem value="단계별단가">단계별단가</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ {/* 선택에 따른 폼: vertical로 출력 */}
+
+
+ {/* 사양이 수기사양일 때 매뉴얼 텍스트 */}
+ {formData.specificationType === '수기사양' && (
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="specificationManualText">사양 매뉴얼 텍스트</Label>
+ <Textarea
+ value={formData.specificationManualText}
+ onChange={(e) => setFormData(prev => ({ ...prev, specificationManualText: e.target.value }))}
+ placeholder="사양 매뉴얼 텍스트를 입력하세요"
+ rows={3}
+ />
+ </div>
+ )}
+
+ </div>
+
+
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 지급/인도 조건 탭 */}
+ <TabsContent value="conditions" className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>Payment & Delivery Conditions (지급/인도 조건)</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-5 gap-6">
+ {/* 납품 전 지급조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">납품 전</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="apBond"
+ checked={formData.paymentBeforeDelivery.apBond || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ apBond: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="apBond" className="text-sm">AP Bond & Performance Bond</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentBeforeDelivery.apBondPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ apBondPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentBeforeDelivery.apBond}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="drawingSubmission"
+ checked={formData.paymentBeforeDelivery.drawingSubmission || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ drawingSubmission: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="drawingSubmission" className="text-sm">도면제출</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentBeforeDelivery.drawingSubmissionPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ drawingSubmissionPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentBeforeDelivery.drawingSubmission}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="materialPurchase"
+ checked={formData.paymentBeforeDelivery.materialPurchase || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ materialPurchase: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="materialPurchase" className="text-sm">소재구매 문서</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentBeforeDelivery.materialPurchasePercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ materialPurchasePercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentBeforeDelivery.materialPurchase}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 납품 지급조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">납품</Label>
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="paymentDelivery">납품 지급조건 <span className="text-red-600">*</span></Label>
+ <Select value={formData.paymentDelivery} onValueChange={(value) => setFormData(prev => ({ ...prev, paymentDelivery: value }))}>
+ <SelectTrigger className={errors.paymentDelivery ? 'border-red-500' : ''}>
+ <SelectValue placeholder="납품 지급조건을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="L/C">L/C</SelectItem>
+ <SelectItem value="T/T">T/T</SelectItem>
+ <SelectItem value="거래명세서 기반 정기지급조건">거래명세서 기반 정기지급조건</SelectItem>
+ <SelectItem value="작업 및 입고 검사 완료">작업 및 입고 검사 완료</SelectItem>
+ <SelectItem value="청구내역서 제출 및 승인">청구내역서 제출 및 승인</SelectItem>
+ <SelectItem value="정규금액 월 단위 정산(지정일 지급)">정규금액 월 단위 정산(지정일 지급)</SelectItem>
+ </SelectContent>
+ </Select>
+ {/* L/C 또는 T/T 선택 시 퍼센트 입력 필드 */}
+ {(formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && (
+ <div className="flex items-center gap-2 mt-2">
+ <Input
+ type="number"
+ min="0"
+ value={paymentDeliveryPercent}
+ onChange={(e) => setPaymentDeliveryPercent(e.target.value)}
+ placeholder="퍼센트"
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-sm text-gray-600">%</span>
+ </div>
+ )}
+ {errors.paymentDelivery && (
+ <p className="text-sm text-red-600">납품 지급조건은 필수값입니다.</p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 납품 외 지급조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">납품 외</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="commissioning"
+ checked={formData.paymentAfterDelivery.commissioning || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ commissioning: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="commissioning" className="text-sm">Commissioning 완료</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentAfterDelivery.commissioningPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ commissioningPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentAfterDelivery.commissioning}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="finalDocument"
+ checked={formData.paymentAfterDelivery.finalDocument || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ finalDocument: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="finalDocument" className="text-sm">최종문서 승인</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentAfterDelivery.finalDocumentPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ finalDocumentPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentAfterDelivery.finalDocument}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="other"
+ checked={formData.paymentAfterDelivery.other || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ other: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="other" className="text-sm">기타</Label>
+ <Input
+ type="text"
+ placeholder="기타 조건을 입력하세요"
+ className="w-48"
+ value={formData.paymentAfterDelivery.otherText || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ otherText: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentAfterDelivery.other}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 지불조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">지불조건</Label>
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="paymentTerm">지불조건 <span className="text-red-600">*</span></Label>
+ <Select
+ value={formData.paymentTerm}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, paymentTerm: value }))}
+ >
+ <SelectTrigger className={errors.paymentTerm ? 'border-red-500' : ''}>
+ <SelectValue placeholder="지불조건을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ {errors.paymentTerm && (
+ <p className="text-sm text-red-600">지불조건은 필수값입니다.</p>
+ )}
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="taxType">세금조건 <span className="text-red-600">*</span></Label>
+ <Select
+ value={formData.taxType}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, taxType: value }))}
+ >
+ <SelectTrigger className={errors.taxType ? 'border-red-500' : ''}>
+ <SelectValue placeholder="세금조건을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {TAX_CONDITIONS.map((condition) => (
+ <SelectItem key={condition.code} value={condition.code}>
+ {condition.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {errors.taxType && (
+ <p className="text-sm text-red-600">세금조건은 필수값입니다.</p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 클레임금액 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">클레임금액</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="liquidatedDamages"
+ checked={formData.liquidatedDamages || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ liquidatedDamages: e.target.checked
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="liquidatedDamages" className="text-sm">지체상금</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.liquidatedDamagesPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ liquidatedDamagesPercent: e.target.value
+ }))}
+ disabled={!formData.liquidatedDamages}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 인도조건 섹션 */}
+ <div className="mt-8">
+ <h3 className="text-lg font-semibold mb-4">인도조건</h3>
+ <div className="grid grid-cols-5 gap-6">
+ {/* 납기종류 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="deliveryType">납기종류</Label>
+ <Select value={formData.deliveryType} onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryType: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="납기종류를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="단일납기">단일납기</SelectItem>
+ <SelectItem value="분할납기">분할납기</SelectItem>
+ <SelectItem value="구간납기">구간납기</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 인도조건 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="deliveryTerm">인도조건</Label>
+ <Select
+ value={formData.deliveryTerm}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryTerm: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="인도조건을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 선적지 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="shippingLocation">선적지</Label>
+ <Select
+ value={formData.shippingLocation}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, shippingLocation: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선적지를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 하역지 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="dischargeLocation">하역지</Label>
+ <Select
+ value={formData.dischargeLocation}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, dischargeLocation: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="하역지를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 계약납기일 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="contractDeliveryDate">계약납기일</Label>
+ <Input
+ type="date"
+ value={formData.contractDeliveryDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractDeliveryDate: e.target.value }))}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 추가 조건 탭 */}
+ <TabsContent value="additional" className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>Additional Conditions (추가조건)</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="contractAmount">계약금액 (자동계산)</Label>
+ <Input
+ type="text"
+ value={contract?.contractAmount ? new Intl.NumberFormat('ko-KR').format(Number(contract.contractAmount)) : '품목정보 없음'}
+ readOnly
+ className="bg-gray-50"
+ placeholder="품목정보에서 자동 계산됩니다"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="currency">계약통화 <span className="text-red-600">*</span></Label>
+ <Input
+ type="text"
+ value={formData.currency}
+ onChange={(e) => setFormData(prev => ({ ...prev, currency: e.target.value }))}
+ placeholder="계약통화를 입력하세요"
+ className={errors.currency ? 'border-red-500' : ''}
+ />
+ {errors.currency && (
+ <p className="text-sm text-red-600">계약통화는 필수값입니다.</p>
+ )}
+ </div>
+
+ {/* 계약성립조건 */}
+ <div className="space-y-4 col-span-2">
+ <Label className="text-base font-medium">계약성립조건</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="regularVendorRegistration"
+ checked={formData.contractEstablishmentConditions.regularVendorRegistration}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractEstablishmentConditions: { ...prev.contractEstablishmentConditions, regularVendorRegistration: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="regularVendorRegistration">정규업체 등록(실사 포함) 시</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="projectAward"
+ checked={formData.contractEstablishmentConditions.projectAward}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractEstablishmentConditions: { ...prev.contractEstablishmentConditions, projectAward: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="projectAward">프로젝트 수주 시</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="ownerApproval"
+ checked={formData.contractEstablishmentConditions.ownerApproval}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractEstablishmentConditions: { ...prev.contractEstablishmentConditions, ownerApproval: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="ownerApproval">선주 승인 시</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="establishmentOther"
+ checked={formData.contractEstablishmentConditions.other}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractEstablishmentConditions: { ...prev.contractEstablishmentConditions, other: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="establishmentOther">기타</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 연동제적용 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">연동제적용</Label>
+ <div className="space-y-2">
+ <Select value={formData.interlockingSystem} onValueChange={(value) => setFormData(prev => ({ ...prev, interlockingSystem: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="연동제적용을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="Y">Y</SelectItem>
+ <SelectItem value="N">N</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ {/* 필수문서동의 */}
+ {/* <div className="space-y-4">
+ <Label className="text-base font-medium">필수문서동의</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="technicalDataAgreement"
+ checked={formData.mandatoryDocuments.technicalDataAgreement}
+ onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, technicalDataAgreement: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="technicalDataAgreement">기술자료제공동의서</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="nda"
+ checked={formData.mandatoryDocuments.nda}
+ onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, nda: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="nda">비밀유지계약서(NDA)</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="basicCompliance"
+ checked={formData.mandatoryDocuments.basicCompliance}
+ onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, basicCompliance: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="basicCompliance">기본준수서약서</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="safetyHealthAgreement"
+ checked={formData.mandatoryDocuments.safetyHealthAgreement}
+ onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, safetyHealthAgreement: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="safetyHealthAgreement">안전보건관리 약정서</Label>
+ </div>
+ </div>
+ </div> */}
+
+ {/* 계약해지조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">계약해지조건</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="standardTermination"
+ checked={formData.contractTerminationConditions.standardTermination}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, standardTermination: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="standardTermination">표준 계약해지조건</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="projectNotAwarded"
+ checked={formData.contractTerminationConditions.projectNotAwarded}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, projectNotAwarded: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="projectNotAwarded">프로젝트 미수주 시</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="terminationOther"
+ checked={formData.contractTerminationConditions.other}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, other: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="terminationOther">기타</Label>
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="notes">비고</Label>
+ <Textarea
+ value={formData.notes}
+ onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
+ placeholder="비고사항을 입력하세요"
+ rows={4}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+
+ {/* 계약첨부문서 탭 */}
+ <TabsContent value="documents" className="space-y-6">
+ <ContractDocuments
+ contractId={contractId}
+ userId={userId?.toString() || "1"}
+ />
+ </TabsContent>
+ </Tabs>
+
+ {/* 저장 버튼 */}
+ <div className="flex justify-end mt-6 pt-4 border-t border-gray-200">
+ <Button
+ onClick={handleSaveContractInfo}
+ disabled={isLoading}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <LoaderIcon className="w-4 h-4 animate-spin" />
+ ) : (
+ <Save className="w-4 h-4" />
+ )}
+ 계약 정보 저장
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/lib/general-contracts/detail/general-contract-communication-channel.tsx b/lib/general-contracts_old/detail/general-contract-communication-channel.tsx
index f5cd79b2..f5cd79b2 100644
--- a/lib/general-contracts/detail/general-contract-communication-channel.tsx
+++ b/lib/general-contracts_old/detail/general-contract-communication-channel.tsx
diff --git a/lib/general-contracts_old/detail/general-contract-detail.tsx b/lib/general-contracts_old/detail/general-contract-detail.tsx
new file mode 100644
index 00000000..8e7a7aff
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-detail.tsx
@@ -0,0 +1,186 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { useParams } from 'next/navigation'
+import Link from 'next/link'
+import { getContractById, getSubcontractChecklist } from '../service'
+import { GeneralContractInfoHeader } from './general-contract-info-header'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { AlertCircle, ArrowLeft } from 'lucide-react'
+import { Skeleton } from '@/components/ui/skeleton'
+import { ContractItemsTable } from './general-contract-items-table'
+import { SubcontractChecklist } from './general-contract-subcontract-checklist'
+import { ContractBasicInfo } from './general-contract-basic-info'
+import { CommunicationChannel } from './general-contract-communication-channel'
+import { Location } from './general-contract-location'
+import { FieldServiceRate } from './general-contract-field-service-rate'
+import { OffsetDetails } from './general-contract-offset-details'
+import { ContractApprovalRequestDialog } from './general-contract-approval-request-dialog'
+
+export default function ContractDetailPage() {
+ const params = useParams()
+ const contractId = params?.id ? parseInt(params.id as string) : null
+
+ const [contract, setContract] = useState<Record<string, unknown> | null>(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState<string | null>(null)
+ const [showApprovalDialog, setShowApprovalDialog] = useState(false)
+ const [subcontractChecklistData, setSubcontractChecklistData] = useState<any>(null)
+
+ useEffect(() => {
+ const fetchContract = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+
+ // 계약 기본 정보 로드
+ const contractData = await getContractById(contractId!)
+ setContract(contractData)
+
+ // 하도급법 체크리스트 데이터 로드
+ try {
+ const checklistData = await getSubcontractChecklist(contractId!)
+ if (checklistData.success && checklistData.data) {
+ setSubcontractChecklistData(checklistData.data)
+ }
+ } catch (checklistError) {
+ console.log('하도급법 체크리스트 데이터 로드 실패:', checklistError)
+ // 체크리스트 로드 실패는 전체 로드를 실패시키지 않음
+ }
+
+ } catch (err) {
+ console.error('Error fetching contract:', err)
+ setError('계약 정보를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (contractId && !isNaN(contractId)) {
+ fetchContract()
+ } else {
+ setError('유효하지 않은 계약 ID입니다.')
+ setLoading(false)
+ }
+ }, [contractId])
+
+ if (loading) {
+ return (
+ <div className="container mx-auto py-6 space-y-6">
+ <Skeleton className="h-8 w-64" />
+ <div className="grid gap-6">
+ <div className="grid grid-cols-2 gap-4">
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ <Skeleton className="h-32 w-full" />
+ </div>
+ </div>
+ )
+ }
+
+ if (error) {
+ return (
+ <div className="container mx-auto py-6">
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ {error}
+ </AlertDescription>
+ </Alert>
+ </div>
+ )
+ }
+
+ return (
+ <div className="container mx-auto py-6 space-y-6">
+
+
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">계약 상세</h1>
+ <p className="text-muted-foreground">
+ 계약번호: {contract?.contractNumber as string} (Rev.{contract?.revision as number})
+ </p>
+ </div>
+ <div className="flex gap-2">
+ {/* 계약승인요청 버튼 */}
+ <Button
+ onClick={() => setShowApprovalDialog(true)}
+ className="bg-blue-600 hover:bg-blue-700"
+ >
+ 계약승인요청
+ </Button>
+ {/* 계약목록으로 돌아가기 버튼 */}
+ <Button asChild variant="outline" size="sm">
+ <Link href="/evcp/general-contracts">
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ 계약목록으로 돌아가기
+ </Link>
+ </Button>
+ </div>
+ </div>
+ {/* 계약 정보 헤더 */}
+ {contract && <GeneralContractInfoHeader contract={contract} />}
+
+ {/* 계약 상세 폼 */}
+ {contract && (
+ <div className="space-y-6">
+ {/* ContractBasicInfo */}
+ <ContractBasicInfo contractId={contract.id as number} />
+ {/* 품목정보 */}
+ {/* {!(contract?.contractScope === '단가' || contract?.contractScope === '물량(실적)') && (
+ <div className="mb-4">
+ <p className="text-sm text-gray-600 mb-2">
+ <strong>품목정보 입력 안내:</strong>
+ <br />
+ 단가/물량 확정 계약의 경우 수량 및 총 계약금액은 별도로 관리됩니다.
+ </p>
+ </div>
+ )} */}
+ <ContractItemsTable
+ contractId={contract.id as number}
+ items={[]}
+ onItemsChange={() => {}}
+ onTotalAmountChange={() => {}}
+ availableBudget={0}
+ readOnly={contract?.contractScope === '단가' || contract?.contractScope === '물량(실적)'}
+ />
+ {/* 하도급법 자율점검 체크리스트 */}
+ <SubcontractChecklist
+ contractId={contract.id as number}
+ onDataChange={(data) => setSubcontractChecklistData(data)}
+ readOnly={false}
+ initialData={subcontractChecklistData}
+ />
+ {/* Communication Channel */}
+ <CommunicationChannel contractId={Number(contract.id)} />
+
+ {/* Location */}
+ <Location contractId={Number(contract.id)} />
+
+ {/* Field Service Rate */}
+ <FieldServiceRate contractId={Number(contract.id)} />
+
+ {/* Offset Details */}
+ <OffsetDetails contractId={Number(contract.id)} />
+ </div>
+ )}
+
+ {/* 계약승인요청 다이얼로그 */}
+ {contract && (
+ <ContractApprovalRequestDialog
+ contract={contract}
+ open={showApprovalDialog}
+ onOpenChange={setShowApprovalDialog}
+ />
+ )}
+ </div>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-documents.tsx b/lib/general-contracts_old/detail/general-contract-documents.tsx
new file mode 100644
index 00000000..b0f20e7f
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-documents.tsx
@@ -0,0 +1,383 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import {
+ Download,
+ Trash2,
+ FileText,
+ LoaderIcon,
+ Paperclip,
+ MessageSquare
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { useTransition } from 'react'
+import {
+ uploadContractAttachment,
+ getContractAttachments,
+ getContractAttachmentForDownload,
+ deleteContractAttachment
+} from '../service'
+import { downloadFile } from '@/lib/file-download'
+
+interface ContractDocument {
+ id: number
+ contractId: number
+ documentName: string
+ fileName: string
+ filePath: string
+ documentType?: string
+ shiComment?: string | null
+ vendorComment?: string | null
+ uploadedAt: Date
+ uploadedById: number
+}
+
+interface ContractDocumentsProps {
+ contractId: number
+ userId: string
+ readOnly?: boolean
+}
+
+export function ContractDocuments({ contractId, userId, readOnly = false }: ContractDocumentsProps) {
+ const [documents, setDocuments] = useState<ContractDocument[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [isPending, startTransition] = useTransition()
+ const [editingComment, setEditingComment] = useState<{ id: number; type: 'shi' | 'vendor' } | null>(null)
+ const [commentText, setCommentText] = useState('')
+ const [selectedDocumentType, setSelectedDocumentType] = useState('')
+
+ const loadDocuments = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ const documentList = await getContractAttachments(contractId)
+ setDocuments(documentList as ContractDocument[])
+ } catch (error) {
+ console.error('Error loading documents:', error)
+ toast.error('문서 목록을 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [contractId])
+
+ useEffect(() => {
+ loadDocuments()
+ }, [loadDocuments])
+
+ const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ if (!selectedDocumentType) {
+ toast.error('문서 유형을 선택해주세요.')
+ return
+ }
+
+ startTransition(async () => {
+ try {
+ // 본 계약문서 타입인 경우 기존 문서 확인
+ if (selectedDocumentType === 'main') {
+ const existingMainDoc = documents.find(doc => doc.documentType === 'main')
+ if (existingMainDoc) {
+ toast.info('기존 계약문서가 새롭게 업로드한 문서로 대체됩니다.')
+ // 기존 본 계약문서 삭제
+ await deleteContractAttachment(existingMainDoc.id, contractId)
+ }
+ }
+
+ await uploadContractAttachment(contractId, file, userId, selectedDocumentType)
+ toast.success('문서가 업로드되었습니다.')
+ loadDocuments()
+ // 파일 입력 초기화
+ event.target.value = ''
+ } catch (error) {
+ console.error('Error uploading document:', error)
+ toast.error('문서 업로드 중 오류가 발생했습니다.')
+ }
+ })
+ }
+
+ const handleDownload = async (document: ContractDocument) => {
+ try {
+ const fileData = await getContractAttachmentForDownload(document.id, contractId)
+ downloadFile(fileData.attachment?.filePath || '', fileData.attachment?.fileName || '', {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Error downloading document:', error)
+ toast.error('문서 다운로드 중 오류가 발생했습니다.')
+ }
+ }
+
+ const handleDelete = async (documentId: number) => {
+
+ startTransition(async () => {
+ try {
+ await deleteContractAttachment(documentId, contractId)
+ toast.success('문서가 삭제되었습니다.')
+ loadDocuments()
+ } catch (error) {
+ console.error('Error deleting document:', error)
+ toast.error('문서 삭제 중 오류가 발생했습니다.')
+ }
+ })
+ }
+
+ const handleEditComment = (documentId: number, type: 'shi' | 'vendor', currentComment?: string) => {
+ setEditingComment({ id: documentId, type })
+ setCommentText(currentComment || '')
+ }
+
+ const handleSaveComment = async () => {
+ if (!editingComment) return
+
+ try {
+ // TODO: API 호출로 댓글 저장
+ toast.success('댓글이 저장되었습니다.')
+ setEditingComment(null)
+ setCommentText('')
+ loadDocuments()
+ } catch (error) {
+ console.error('Error saving comment:', error)
+ toast.error('댓글 저장 중 오류가 발생했습니다.')
+ }
+ }
+
+ const getDocumentTypeLabel = (documentName: string) => {
+ switch (documentName) {
+ case 'specification': return '사양'
+ case 'pricing': return '단가종류'
+ case 'other': return '기타'
+ default: return documentName
+ }
+ }
+
+ const getDocumentTypeColor = (documentName: string) => {
+ switch (documentName) {
+ case 'specification': return 'bg-blue-100 text-blue-800'
+ case 'pricing': return 'bg-green-100 text-green-800'
+ case 'other': return 'bg-gray-100 text-gray-800'
+ default: return 'bg-gray-100 text-gray-800'
+ }
+ }
+
+ const groupedDocuments = documents.reduce((acc, doc) => {
+ const type = doc.documentName
+ if (!acc[type]) {
+ acc[type] = []
+ }
+ acc[type].push(doc)
+ return acc
+ }, {} as Record<string, ContractDocument[]>)
+
+ const documentTypes = [
+ { value: 'specification', label: '사양' },
+ { value: 'pricing', label: '단가종류' },
+ { value: 'other', label: '기타' }
+ ]
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Paperclip className="h-5 w-5" />
+ 계약 첨부문서
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 파일 업로드 */}
+ {!readOnly && (
+ <div className="space-y-4">
+ <div className="flex items-center gap-4">
+ <Select value={selectedDocumentType} onValueChange={setSelectedDocumentType}>
+ <SelectTrigger className="w-40">
+ <SelectValue placeholder="문서 유형" />
+ </SelectTrigger>
+ <SelectContent>
+ {documentTypes.map((type) => (
+ <SelectItem key={type.value} value={type.value}>
+ {type.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <Input
+ type="file"
+ onChange={handleFileUpload}
+ disabled={isPending}
+ className="flex-1"
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 문서 목록 */}
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <LoaderIcon className="h-6 w-6 animate-spin" />
+ <span className="ml-2">문서를 불러오는 중...</span>
+ </div>
+ ) : documents.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>업로드된 문서가 없습니다.</p>
+ </div>
+ ) : (
+ <div className="space-y-6">
+ {Object.entries(groupedDocuments).map(([type, docs]) => (
+ <div key={type} className="space-y-3">
+ <div className="flex items-center gap-2">
+ <Badge className={getDocumentTypeColor(type)}>
+ {getDocumentTypeLabel(type)}
+ </Badge>
+ <span className="text-sm text-muted-foreground">
+ {docs.length}개 문서
+ </span>
+ </div>
+
+ <div className="space-y-3">
+ {docs.map((doc) => (
+ <div key={doc.id} className="border rounded-lg p-4 space-y-3">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <FileText className="h-5 w-5 text-muted-foreground" />
+ <div>
+ <p className="font-medium">{doc.fileName}</p>
+ <p className="text-sm text-muted-foreground">
+ 업로드: {new Date(doc.uploadedAt).toLocaleDateString()}
+ </p>
+ </div>
+ </div>
+
+ {!readOnly && (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleDownload(doc)}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleDelete(doc.id)}
+ className="text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ )}
+ </div>
+
+ {/* 댓글 섹션 */}
+ <div className="grid grid-cols-2 gap-4">
+ {/* SHI 댓글 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-sm font-medium">SHI 댓글</Label>
+ {!readOnly && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleEditComment(doc.id, 'shi', doc.shiComment || '')}
+ >
+ <MessageSquare className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ {editingComment?.id === doc.id && editingComment.type === 'shi' ? (
+ <div className="space-y-2">
+ <Textarea
+ value={commentText}
+ onChange={(e) => setCommentText(e.target.value)}
+ placeholder="SHI 댓글을 입력하세요"
+ rows={3}
+ />
+ <div className="flex gap-2">
+ <Button size="sm" onClick={handleSaveComment}>
+ 저장
+ </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => setEditingComment(null)}
+ >
+ 취소
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="min-h-[60px] p-3 bg-gray-50 rounded border">
+ {doc.shiComment ? (
+ <p className="text-sm">{doc.shiComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">댓글이 없습니다.</p>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Vendor 댓글 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-sm font-medium">Vendor 댓글</Label>
+ {!readOnly && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleEditComment(doc.id, 'vendor', doc.vendorComment || '')}
+ >
+ <MessageSquare className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ {editingComment?.id === doc.id && editingComment.type === 'vendor' ? (
+ <div className="space-y-2">
+ <Textarea
+ value={commentText}
+ onChange={(e) => setCommentText(e.target.value)}
+ placeholder="Vendor 댓글을 입력하세요"
+ rows={3}
+ />
+ <div className="flex gap-2">
+ <Button size="sm" onClick={handleSaveComment}>
+ 저장
+ </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => setEditingComment(null)}
+ >
+ 취소
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="min-h-[60px] p-3 bg-gray-50 rounded border">
+ {doc.vendorComment ? (
+ <p className="text-sm">{doc.vendorComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">댓글이 없습니다.</p>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/lib/general-contracts/detail/general-contract-field-service-rate.tsx b/lib/general-contracts_old/detail/general-contract-field-service-rate.tsx
index a8158307..a8158307 100644
--- a/lib/general-contracts/detail/general-contract-field-service-rate.tsx
+++ b/lib/general-contracts_old/detail/general-contract-field-service-rate.tsx
diff --git a/lib/general-contracts_old/detail/general-contract-info-header.tsx b/lib/general-contracts_old/detail/general-contract-info-header.tsx
new file mode 100644
index 00000000..9be9840d
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-info-header.tsx
@@ -0,0 +1,211 @@
+import { Building2, Package, DollarSign, Calendar, FileText } from 'lucide-react'
+import { formatDate } from '@/lib/utils'
+
+interface GeneralContractInfoHeaderProps {
+ contract: {
+ id: number
+ contractNumber: string
+ revision: number
+ status: string
+ category: string
+ type: string
+ name: string
+ vendorName?: string
+ vendorCode?: string
+ startDate: string
+ endDate: string
+ validityEndDate: string
+ contractAmount?: string
+ currency?: string
+ registeredAt: string
+ signedAt?: string
+ linkedRfqOrItb?: string
+ linkedBidNumber?: string
+ linkedPoNumber?: string
+ }
+}
+
+const statusLabels = {
+ 'Draft': '임시저장',
+ 'Request to Review': '조건검토요청',
+ 'Confirm to Review': '조건검토완료',
+ 'Contract Accept Request': '계약승인요청',
+ 'Complete the Contract': '계약체결',
+ 'Reject to Accept Contract': '계약승인거절',
+ 'Contract Delete': '계약폐기',
+ 'PCR Request': 'PCR요청',
+ 'VO Request': 'VO요청',
+ 'PCR Accept': 'PCR승인',
+ 'PCR Reject': 'PCR거절'
+}
+
+const categoryLabels = {
+ '단가계약': '단가계약',
+ '일반계약': '일반계약',
+ '매각계약': '매각계약'
+}
+
+const typeLabels = {
+ 'UP': '자재단가계약',
+ 'LE': '임대차계약',
+ 'IL': '개별운송계약',
+ 'AL': '연간운송계약',
+ 'OS': '외주용역계약',
+ 'OW': '도급계약',
+ 'IS': '검사계약',
+ 'LO': 'LOI',
+ 'FA': 'FA',
+ 'SC': '납품합의계약',
+ 'OF': '클레임상계계약',
+ 'AW': '사전작업합의',
+ 'AD': '사전납품합의',
+ 'AM': '설계계약',
+ 'SC_SELL': '폐기물매각계약'
+}
+
+export function GeneralContractInfoHeader({ contract }: GeneralContractInfoHeaderProps) {
+ return (
+ <div className="bg-white border rounded-lg p-6 mb-6 shadow-sm">
+ {/* 3개 섹션을 Grid로 배치 - 각 섹션이 동일한 width로 꽉 채움 */}
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+ {/* 왼쪽 섹션: 계약 기본 정보 */}
+ <div className="w-full space-y-4">
+ {/* 계약번호 */}
+ <div className="mb-4">
+ <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
+ <FileText className="w-4 h-4" />
+ <span>계약번호 (Rev.)</span>
+ </div>
+ <div className="font-mono font-medium text-gray-900">
+ {contract.contractNumber} (Rev.{contract.revision})
+ </div>
+ </div>
+
+ {/* 계약명 */}
+ <div className="mb-4">
+ <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
+ <Package className="w-4 h-4" />
+ <span>계약명</span>
+ </div>
+ <div className="font-medium text-gray-900">{contract.name}</div>
+ </div>
+
+ {/* 협력업체 */}
+ <div className="mb-4">
+ <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
+ <Building2 className="w-4 h-4" />
+ <span>협력업체</span>
+ </div>
+ <div className="font-medium text-gray-900">
+ {contract.vendorName || '협력업체 미선택'}
+ {contract.vendorCode && (
+ <span className="text-sm text-gray-500 ml-2">({contract.vendorCode})</span>
+ )}
+ </div>
+ </div>
+
+
+ {/* 계약금액 */}
+ {contract.contractAmount && (
+ <div className="mb-4">
+ <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
+ <DollarSign className="w-4 h-4" />
+ <span>계약금액</span>
+ </div>
+ <div className="font-semibold text-gray-900">
+ {new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: contract.currency || 'KRW',
+ }).format(Number(contract.contractAmount))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 가운데 섹션: 계약 분류 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6">
+ <div className="space-y-3">
+ <div className="flex flex-col gap-1">
+ <span className="text-gray-500 text-sm">계약상태</span>
+ <span className="font-medium">{statusLabels[contract.status as keyof typeof statusLabels] || contract.status}</span>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <span className="text-gray-500 text-sm">계약구분</span>
+ <span className="font-medium">{categoryLabels[contract.category as keyof typeof categoryLabels] || contract.category}</span>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <span className="text-gray-500 text-sm">계약종류</span>
+ <span className="font-medium">{typeLabels[contract.type as keyof typeof typeLabels] || contract.type}</span>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <span className="text-gray-500 text-sm">통화</span>
+ <span className="font-mono font-medium">{contract.currency || 'KRW'}</span>
+ </div>
+ </div>
+ </div>
+
+ {/* 오른쪽 섹션: 일정 및 연계 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6">
+ <div className="flex items-center gap-2 mb-3 text-sm text-gray-500">
+ <Calendar className="w-4 h-4" />
+ <span>일정 및 연계 정보</span>
+ </div>
+ <div className="space-y-3">
+ <div>
+ <span className="text-gray-500 text-sm">계약기간</span>
+ <div className="font-medium">
+ {formatDate(contract.startDate, 'KR')} ~ {formatDate(contract.endDate, 'KR')}
+ </div>
+ </div>
+
+ <div>
+ <span className="text-gray-500 text-sm">계약 유효기간</span>
+ <div className="font-medium">{formatDate(contract.validityEndDate, 'KR')}</div>
+ </div>
+
+ {contract.signedAt && (
+ <div>
+ <span className="text-gray-500 text-sm">계약체결일</span>
+ <div className="font-medium">{formatDate(contract.signedAt, 'KR')}</div>
+ </div>
+ )}
+
+ {contract.registeredAt && (
+ <div>
+ <span className="text-gray-500 text-sm">등록일</span>
+ <div className="font-medium">{formatDate(contract.registeredAt, 'KR')}</div>
+ </div>
+ )}
+
+ {(contract.linkedRfqOrItb || contract.linkedBidNumber || contract.linkedPoNumber) && (
+ <div className="space-y-2">
+ <span className="text-gray-500 text-sm font-medium">연계 정보</span>
+ {contract.linkedRfqOrItb && (
+ <div>
+ <span className="text-gray-500 text-xs">연계 견적/입찰번호</span>
+ <div className="font-medium text-sm">{contract.linkedRfqOrItb}</div>
+ </div>
+ )}
+ {contract.linkedBidNumber && (
+ <div>
+ <span className="text-gray-500 text-xs">연계 BID번호</span>
+ <div className="font-medium text-sm">{contract.linkedBidNumber}</div>
+ </div>
+ )}
+ {contract.linkedPoNumber && (
+ <div>
+ <span className="text-gray-500 text-xs">연계 PO번호</span>
+ <div className="font-medium text-sm">{contract.linkedPoNumber}</div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-items-table.tsx b/lib/general-contracts_old/detail/general-contract-items-table.tsx
new file mode 100644
index 00000000..1b9a1a06
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-items-table.tsx
@@ -0,0 +1,602 @@
+'use client'
+
+import * as React from 'react'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Package,
+ Plus,
+ Trash2,
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { updateContractItems, getContractItems } from '../service'
+import { Save, LoaderIcon } from 'lucide-react'
+
+interface ContractItem {
+ id?: number
+ itemCode: string
+ itemInfo: string
+ specification: string
+ quantity: number
+ quantityUnit: string
+ totalWeight: number
+ weightUnit: string
+ contractDeliveryDate: string
+ contractUnitPrice: number
+ contractAmount: number
+ contractCurrency: string
+ isSelected?: boolean
+ [key: string]: unknown
+}
+
+interface ContractItemsTableProps {
+ contractId: number
+ items: ContractItem[]
+ onItemsChange: (items: ContractItem[]) => void
+ onTotalAmountChange: (total: number) => void
+ availableBudget?: number
+ readOnly?: boolean
+}
+
+// 통화 목록
+const CURRENCIES = ["USD", "EUR", "KRW", "JPY", "CNY"];
+
+// 수량 단위 목록
+const QUANTITY_UNITS = ["KG", "TON", "EA", "M", "M2", "M3", "L", "ML", "G", "SET", "PCS"];
+
+// 중량 단위 목록
+const WEIGHT_UNITS = ["KG", "TON", "G", "LB", "OZ"];
+
+export function ContractItemsTable({
+ contractId,
+ items,
+ onItemsChange,
+ onTotalAmountChange,
+ availableBudget = 0,
+ readOnly = false
+}: ContractItemsTableProps) {
+ const [localItems, setLocalItems] = React.useState<ContractItem[]>(items)
+ const [isSaving, setIsSaving] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isEnabled, setIsEnabled] = React.useState(true)
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ const loadItems = async () => {
+ try {
+ setIsLoading(true)
+ const fetchedItems = await getContractItems(contractId)
+ const formattedItems = fetchedItems.map(item => ({
+ id: item.id,
+ itemCode: item.itemCode || '',
+ itemInfo: item.itemInfo || '',
+ specification: item.specification || '',
+ quantity: Number(item.quantity) || 0,
+ quantityUnit: item.quantityUnit || 'EA',
+ totalWeight: Number(item.totalWeight) || 0,
+ weightUnit: item.weightUnit || 'KG',
+ contractDeliveryDate: item.contractDeliveryDate || '',
+ contractUnitPrice: Number(item.contractUnitPrice) || 0,
+ contractAmount: Number(item.contractAmount) || 0,
+ contractCurrency: item.contractCurrency || 'KRW',
+ isSelected: false
+ })) as ContractItem[]
+ setLocalItems(formattedItems as ContractItem[])
+ onItemsChange(formattedItems as ContractItem[])
+ } catch (error) {
+ console.error('Error loading contract items:', error)
+ // 기본 빈 배열로 설정
+ setLocalItems([])
+ onItemsChange([])
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadItems()
+ }, [contractId, onItemsChange])
+
+ // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선)
+ React.useEffect(() => {
+ if (items.length > 0) {
+ setLocalItems(items)
+ }
+ }, [items])
+
+ const handleSaveItems = async () => {
+ try {
+ setIsSaving(true)
+
+ // validation 체크
+ const errors: string[] = []
+ for (let index = 0; index < localItems.length; index++) {
+ const item = localItems[index]
+ if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
+ if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`)
+ if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
+ if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`)
+ if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`)
+ }
+
+ if (errors.length > 0) {
+ toast.error(`다음 항목을 입력해주세요: ${errors.join(', ')}`)
+ return
+ }
+
+ await updateContractItems(contractId, localItems as any)
+ toast.success('품목정보가 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving contract items:', error)
+ toast.error('품목정보 저장 중 오류가 발생했습니다.')
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // 총 금액 계산
+ const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0)
+ const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0)
+ const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0)
+ const amountDifference = availableBudget - totalAmount
+ const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0
+
+ // 부모 컴포넌트에 총 금액 전달
+ React.useEffect(() => {
+ onTotalAmountChange(totalAmount)
+ }, [totalAmount, onTotalAmountChange])
+
+ // 아이템 업데이트
+ const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => {
+ const updatedItems = [...localItems]
+ updatedItems[index] = { ...updatedItems[index], [field]: value }
+
+ // 단가나 수량이 변경되면 금액 자동 계산
+ if (field === 'contractUnitPrice' || field === 'quantity') {
+ const item = updatedItems[index]
+ updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity
+ }
+
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ }
+
+ // 행 추가
+ const addRow = () => {
+ const newItem: ContractItem = {
+ itemCode: '',
+ itemInfo: '',
+ specification: '',
+ quantity: 0,
+ quantityUnit: 'EA', // 기본 수량 단위
+ totalWeight: 0,
+ weightUnit: 'KG', // 기본 중량 단위
+ contractDeliveryDate: '',
+ contractUnitPrice: 0,
+ contractAmount: 0,
+ contractCurrency: 'KRW', // 기본 통화
+ isSelected: false
+ }
+ const updatedItems = [...localItems, newItem]
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ }
+
+ // 선택된 행 삭제
+ const deleteSelectedRows = () => {
+ const selectedIndices = localItems
+ .map((item, index) => item.isSelected ? index : -1)
+ .filter(index => index !== -1)
+
+ if (selectedIndices.length === 0) {
+ toast.error("삭제할 행을 선택해주세요.")
+ return
+ }
+
+ const updatedItems = localItems.filter((_, index) => !selectedIndices.includes(index))
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ toast.success(`${selectedIndices.length}개 행이 삭제되었습니다.`)
+ }
+
+ // 전체 선택/해제
+ const toggleSelectAll = (checked: boolean) => {
+ const updatedItems = localItems.map(item => ({ ...item, isSelected: checked }))
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ }
+
+
+ // 통화 포맷팅
+ const formatCurrency = (amount: number, currency: string = 'KRW') => {
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: currency,
+ }).format(amount)
+ }
+
+ const allSelected = localItems.length > 0 && localItems.every(item => item.isSelected)
+ const someSelected = localItems.some(item => item.isSelected)
+
+ if (isLoading) {
+ return (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="items">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ <span>품목 정보</span>
+ <span className="text-sm text-gray-500">(로딩 중...)</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="flex items-center justify-center py-8">
+ <LoaderIcon className="w-6 h-6 animate-spin mr-2" />
+ <span>품목 정보를 불러오는 중...</span>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )
+ }
+
+ return (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="items">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-3 w-full">
+ <Package className="w-5 h-5" />
+ <span className="font-medium">품목 정보</span>
+ <span className="text-sm text-gray-500">({localItems.length}개 품목)</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <Card>
+ <CardHeader>
+ {/* 체크박스 */}
+ <div className="flex items-center gap-2 mb-4">
+ <Checkbox
+ checked={isEnabled}
+ onCheckedChange={(checked) => setIsEnabled(checked as boolean)}
+ disabled={readOnly}
+ />
+ <span className="text-sm font-medium">품목 정보 활성화</span>
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-gray-600">총 금액: {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}</span>
+ <span className="text-sm text-gray-600">총 수량: {totalQuantity.toLocaleString()}</span>
+ </div>
+ {!readOnly && (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={addRow}
+ disabled={!isEnabled}
+ className="flex items-center gap-2"
+ >
+ <Plus className="w-4 h-4" />
+ 행 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={deleteSelectedRows}
+ disabled={!isEnabled}
+ className="flex items-center gap-2 text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="w-4 h-4" />
+ 행 삭제
+ </Button>
+ <Button
+ onClick={handleSaveItems}
+ disabled={isSaving || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ {isSaving ? (
+ <LoaderIcon className="w-4 h-4 animate-spin" />
+ ) : (
+ <Save className="w-4 h-4" />
+ )}
+ 품목정보 저장
+ </Button>
+ </div>
+ )}
+ </div>
+
+ {/* 요약 정보 */}
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">총 계약금액</Label>
+ <div className="text-lg font-bold text-primary">
+ {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
+ </div>
+ </div>
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">가용예산</Label>
+ <div className="text-lg font-bold">
+ {formatCurrency(availableBudget, localItems[0]?.contractCurrency || 'KRW')}
+ </div>
+ </div>
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">가용예산 比 (금액차)</Label>
+ <div className={`text-lg font-bold ${amountDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}>
+ {formatCurrency(amountDifference, localItems[0]?.contractCurrency || 'KRW')}
+ </div>
+ </div>
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">가용예산 比 (비율)</Label>
+ <div className={`text-lg font-bold ${budgetRatio <= 100 ? 'text-green-600' : 'text-red-600'}`}>
+ {budgetRatio.toFixed(1)}%
+ </div>
+ </div>
+ </div>
+ </CardHeader>
+
+ <CardContent>
+ <div className="overflow-x-auto">
+ <Table>
+ <TableHeader>
+ <TableRow className="border-b-2">
+ <TableHead className="w-12 px-2">
+ {!readOnly && (
+ <Checkbox
+ checked={allSelected}
+ ref={(el) => {
+ if (el) (el as HTMLInputElement & { indeterminate?: boolean }).indeterminate = someSelected && !allSelected
+ }}
+ onCheckedChange={toggleSelectAll}
+ disabled={!isEnabled}
+ />
+ )}
+ </TableHead>
+ <TableHead className="px-3 py-3 font-semibold">품목코드 (PKG No.)</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">Item 정보 (자재그룹 / 자재코드)</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">규격</TableHead>
+ <TableHead className="px-3 py-3 font-semibold text-right">수량</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">수량단위</TableHead>
+ <TableHead className="px-3 py-3 font-semibold text-right">총 중량</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">중량단위</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">계약납기일</TableHead>
+ <TableHead className="px-3 py-3 font-semibold text-right">계약단가</TableHead>
+ <TableHead className="px-3 py-3 font-semibold text-right">계약금액</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">계약통화</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {localItems.map((item, index) => (
+ <TableRow key={index} className="hover:bg-muted/30 transition-colors">
+ <TableCell className="px-2">
+ {!readOnly && (
+ <Checkbox
+ checked={item.isSelected || false}
+ onCheckedChange={(checked) =>
+ updateItem(index, 'isSelected', checked)
+ }
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.itemCode || '-'}</span>
+ ) : (
+ <Input
+ value={item.itemCode}
+ onChange={(e) => updateItem(index, 'itemCode', e.target.value)}
+ placeholder="품목코드"
+ className="h-8 text-sm"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.itemInfo || '-'}</span>
+ ) : (
+ <Input
+ value={item.itemInfo}
+ onChange={(e) => updateItem(index, 'itemInfo', e.target.value)}
+ placeholder="Item 정보"
+ className="h-8 text-sm"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.specification || '-'}</span>
+ ) : (
+ <Input
+ value={item.specification}
+ onChange={(e) => updateItem(index, 'specification', e.target.value)}
+ placeholder="규격"
+ className="h-8 text-sm"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm text-right">{item.quantity.toLocaleString()}</span>
+ ) : (
+ <Input
+ type="number"
+ value={item.quantity}
+ onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
+ className="h-8 text-sm text-right"
+ placeholder="0"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.quantityUnit || '-'}</span>
+ ) : (
+ <Select
+ value={item.quantityUnit}
+ onValueChange={(value) => updateItem(index, 'quantityUnit', value)}
+ disabled={!isEnabled}
+ >
+ <SelectTrigger className="h-8 text-sm w-20">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {QUANTITY_UNITS.map((unit) => (
+ <SelectItem key={unit} value={unit}>
+ {unit}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm text-right">{item.totalWeight.toLocaleString()}</span>
+ ) : (
+ <Input
+ type="number"
+ value={item.totalWeight}
+ onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)}
+ className="h-8 text-sm text-right"
+ placeholder="0"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.weightUnit || '-'}</span>
+ ) : (
+ <Select
+ value={item.weightUnit}
+ onValueChange={(value) => updateItem(index, 'weightUnit', value)}
+ disabled={!isEnabled}
+ >
+ <SelectTrigger className="h-8 text-sm w-20">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {WEIGHT_UNITS.map((unit) => (
+ <SelectItem key={unit} value={unit}>
+ {unit}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.contractDeliveryDate || '-'}</span>
+ ) : (
+ <Input
+ type="date"
+ value={item.contractDeliveryDate}
+ onChange={(e) => updateItem(index, 'contractDeliveryDate', e.target.value)}
+ className="h-8 text-sm"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm text-right">{item.contractUnitPrice.toLocaleString()}</span>
+ ) : (
+ <Input
+ type="number"
+ value={item.contractUnitPrice}
+ onChange={(e) => updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)}
+ className="h-8 text-sm text-right"
+ placeholder="0"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ <div className="font-semibold text-primary text-right text-sm">
+ {formatCurrency(item.contractAmount)}
+ </div>
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.contractCurrency || '-'}</span>
+ ) : (
+ <Select
+ value={item.contractCurrency}
+ onValueChange={(value) => updateItem(index, 'contractCurrency', value)}
+ disabled={!isEnabled}
+ >
+ <SelectTrigger className="h-8 text-sm w-20">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {CURRENCIES.map((currency) => (
+ <SelectItem key={currency} value={currency}>
+ {currency}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 합계 정보 */}
+ {localItems.length > 0 && (
+ <div className="mt-6 flex justify-end">
+ <Card className="w-80 bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">
+ <CardContent className="p-6">
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium text-muted-foreground">총 수량</span>
+ <span className="text-lg font-semibold">
+ {totalQuantity.toLocaleString()} {localItems[0]?.quantityUnit || 'KG'}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium text-muted-foreground">총 단가</span>
+ <span className="text-lg font-semibold">
+ {formatCurrency(totalUnitPrice, localItems[0]?.contractCurrency || 'KRW')}
+ </span>
+ </div>
+ <div className="border-t pt-4">
+ <div className="flex items-center justify-between">
+ <span className="text-xl font-bold text-primary">합계 금액</span>
+ <span className="text-2xl font-bold text-primary">
+ {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
+ </span>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )
+}
diff --git a/lib/general-contracts/detail/general-contract-location.tsx b/lib/general-contracts_old/detail/general-contract-location.tsx
index 5b388895..5b388895 100644
--- a/lib/general-contracts/detail/general-contract-location.tsx
+++ b/lib/general-contracts_old/detail/general-contract-location.tsx
diff --git a/lib/general-contracts/detail/general-contract-offset-details.tsx b/lib/general-contracts_old/detail/general-contract-offset-details.tsx
index af4f2ef2..af4f2ef2 100644
--- a/lib/general-contracts/detail/general-contract-offset-details.tsx
+++ b/lib/general-contracts_old/detail/general-contract-offset-details.tsx
diff --git a/lib/general-contracts_old/detail/general-contract-subcontract-checklist.tsx b/lib/general-contracts_old/detail/general-contract-subcontract-checklist.tsx
new file mode 100644
index 00000000..ce7c8baf
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-subcontract-checklist.tsx
@@ -0,0 +1,610 @@
+'use client'
+
+import React, { useState } from 'react'
+import { Card, CardContent } from '@/components/ui/card'
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { Badge } from '@/components/ui/badge'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Button } from '@/components/ui/button'
+import { updateSubcontractChecklist } from '../service'
+import { toast } from 'sonner'
+import { AlertTriangle, CheckCircle, XCircle, HelpCircle, Save } from 'lucide-react'
+
+interface SubcontractChecklistData {
+ // 1. 계약서면발급
+ contractDocumentIssuance: {
+ workOrderBeforeStart: boolean
+ entrustmentDetails: boolean
+ deliveryDetails: boolean
+ inspectionMethod: boolean
+ subcontractPayment: boolean
+ materialProvision: boolean
+ priceAdjustment: boolean
+ }
+ // 2. 부당하도급대금결정행위
+ unfairSubcontractPricing: {
+ priceReductionWithBasis: boolean
+ noNegotiationAfterLowestBid: boolean
+ noDeceptionInPricing: boolean
+ noUniformPriceReduction: boolean
+ noDiscriminatoryTreatment: boolean
+ }
+ // 점검결과
+ inspectionResult: 'compliant' | 'violation' | 'suspected_violation'
+ // 귀책부서 (위반/위반의심 시 필수)
+ responsibleDepartment?: string
+ // 원인 (위반/위반의심 시 필수)
+ cause?: string
+ causeOther?: string
+ // 대책 (위반/위반의심 시 필수)
+ countermeasure?: string
+ countermeasureOther?: string
+}
+
+interface SubcontractChecklistProps {
+ contractId: number
+ onDataChange: (data: SubcontractChecklistData) => void
+ readOnly?: boolean
+ initialData?: SubcontractChecklistData
+}
+
+export function SubcontractChecklist({ contractId, onDataChange, readOnly = false, initialData }: SubcontractChecklistProps) {
+ // 기본 데이터 구조
+ const defaultData: SubcontractChecklistData = {
+ contractDocumentIssuance: {
+ workOrderBeforeStart: false,
+ entrustmentDetails: false,
+ deliveryDetails: false,
+ inspectionMethod: false,
+ subcontractPayment: false,
+ materialProvision: false,
+ priceAdjustment: false,
+ },
+ unfairSubcontractPricing: {
+ priceReductionWithBasis: false,
+ noNegotiationAfterLowestBid: false,
+ noDeceptionInPricing: false,
+ noUniformPriceReduction: false,
+ noDiscriminatoryTreatment: false,
+ },
+ inspectionResult: 'compliant',
+ }
+
+ // initialData와 기본값을 깊이 병합
+ const mergedInitialData = React.useMemo(() => {
+ if (!initialData) return defaultData
+
+ return {
+ contractDocumentIssuance: {
+ ...defaultData.contractDocumentIssuance,
+ ...(initialData.contractDocumentIssuance || {}),
+ },
+ unfairSubcontractPricing: {
+ ...defaultData.unfairSubcontractPricing,
+ ...(initialData.unfairSubcontractPricing || {}),
+ },
+ inspectionResult: initialData.inspectionResult || defaultData.inspectionResult,
+ responsibleDepartment: initialData.responsibleDepartment,
+ cause: initialData.cause,
+ causeOther: initialData.causeOther,
+ countermeasure: initialData.countermeasure,
+ countermeasureOther: initialData.countermeasureOther,
+ }
+ }, [initialData])
+
+ const [isEnabled, setIsEnabled] = useState(true)
+ const [data, setData] = useState<SubcontractChecklistData>(mergedInitialData)
+
+ // 점검결과 자동 계산 함수
+ const calculateInspectionResult = (
+ contractDocumentIssuance: SubcontractChecklistData['contractDocumentIssuance'],
+ unfairSubcontractPricing: SubcontractChecklistData['unfairSubcontractPricing']
+ ): 'compliant' | 'violation' | 'suspected_violation' => {
+ // 1. 계약서면발급의 모든 항목이 체크되어야 함
+ const allContractItemsChecked = Object.values(contractDocumentIssuance).every(checked => checked)
+
+ // 2. 부당하도급대금결정행위에서 'X' 항목 체크 확인
+ const hasUnfairPricingViolation = Object.values(unfairSubcontractPricing).some(checked => !checked)
+
+ if (!allContractItemsChecked) {
+ return 'violation'
+ } else if (hasUnfairPricingViolation) {
+ return 'suspected_violation'
+ }
+
+ return 'compliant'
+ }
+
+ const handleContractDocumentChange = (field: keyof SubcontractChecklistData['contractDocumentIssuance'], checked: boolean) => {
+ setData(prev => {
+ const newContractDocumentIssuance = {
+ ...prev.contractDocumentIssuance,
+ [field]: checked
+ }
+ const newInspectionResult = calculateInspectionResult(newContractDocumentIssuance, prev.unfairSubcontractPricing)
+
+ return {
+ ...prev,
+ contractDocumentIssuance: newContractDocumentIssuance,
+ inspectionResult: newInspectionResult
+ }
+ })
+ }
+
+ const handleUnfairPricingChange = (field: keyof SubcontractChecklistData['unfairSubcontractPricing'], checked: boolean) => {
+ setData(prev => {
+ const newUnfairSubcontractPricing = {
+ ...prev.unfairSubcontractPricing,
+ [field]: checked
+ }
+ const newInspectionResult = calculateInspectionResult(prev.contractDocumentIssuance, newUnfairSubcontractPricing)
+
+ return {
+ ...prev,
+ unfairSubcontractPricing: newUnfairSubcontractPricing,
+ inspectionResult: newInspectionResult
+ }
+ })
+ }
+
+ const handleFieldChange = (field: keyof SubcontractChecklistData, value: string) => {
+ setData(prev => ({ ...prev, [field]: value }))
+ }
+
+ // 데이터 변경 시 부모 컴포넌트에 전달 (저장 시에만)
+ const handleSave = async () => {
+ try {
+ // validation 체크
+ const errors = []
+
+ // 위반 또는 위반의심인 경우 필수 필드 체크
+ if (data.inspectionResult === 'violation' || data.inspectionResult === 'suspected_violation') {
+ if (!data.responsibleDepartment) errors.push('귀책부서')
+ if (!data.cause) errors.push('원인')
+ if (!data.countermeasure) errors.push('대책')
+
+ // 기타 선택 시 추가 입력 필드 체크
+ if (data.cause === '기타' && !data.causeOther) errors.push('원인 기타 입력')
+ if (data.countermeasure === '기타' && !data.countermeasureOther) errors.push('대책 기타 입력')
+ }
+
+ if (errors.length > 0) {
+ toast.error(`다음 항목을 입력해주세요: ${errors.join(', ')}`)
+ return
+ }
+
+ await updateSubcontractChecklist(contractId, data)
+ onDataChange(data)
+ toast.success('하도급법 체크리스트가 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving subcontract checklist:', error)
+ toast.error('하도급법 체크리스트 저장 중 오류가 발생했습니다.')
+ }
+ }
+
+ const getInspectionResultInfo = () => {
+ switch (data.inspectionResult) {
+ case 'compliant':
+ return {
+ icon: <CheckCircle className="h-5 w-5 text-green-600" />,
+ label: '준수',
+ color: 'bg-green-100 text-green-800',
+ description: '1. 계약서면발급의 모든 항목에 체크, 2. 부당하도급에서 X항목에 체크한 상태'
+ }
+ case 'violation':
+ return {
+ icon: <XCircle className="h-5 w-5 text-red-600" />,
+ label: '위반',
+ color: 'bg-red-100 text-red-800',
+ description: '1. 계약서면발급의 모든 항목 중 1개 이상 미체크 한 경우'
+ }
+ case 'suspected_violation':
+ return {
+ icon: <AlertTriangle className="h-5 w-5 text-yellow-600" />,
+ label: '위반의심',
+ color: 'bg-yellow-100 text-yellow-800',
+ description: '2. 부당하도급에서 O항목에 체크한 경우'
+ }
+ default:
+ // 기본값으로 준수 상태 반환
+ return {
+ icon: <CheckCircle className="h-5 w-5 text-green-600" />,
+ label: '준수',
+ color: 'bg-green-100 text-green-800',
+ description: '점검 결과가 유효하지 않습니다.'
+ }
+ }
+ }
+
+ const resultInfo = getInspectionResultInfo()
+ const isViolationOrSuspected = data.inspectionResult === 'violation' || data.inspectionResult === 'suspected_violation'
+
+ const causeOptions = [
+ { value: '서면미교부_현업부서 하도급법 이해 부족', label: '서면미교부_현업부서 하도급법 이해 부족' },
+ { value: '서면미교부_기존계약 만료前 계약연장에 대한 사전조치 소홀', label: '서면미교부_기존계약 만료前 계약연장에 대한 사전조치 소홀' },
+ { value: '서면미교부_긴급작업時 先작업합의서 체결 절차 未인지', label: '서면미교부_긴급작업時 先작업합의서 체결 절차 未인지' },
+ { value: '부당가격인하_예산부족 等 원가절감 필요성 대두', label: '부당가격인하_예산부족 等 원가절감 필요성 대두' },
+ { value: '부당가격인하_하도급법 이해부족 및 금액 협의과정에 대한 근거 미흡', label: '부당가격인하_하도급법 이해부족 및 금액 협의과정에 대한 근거 미흡' },
+ { value: '기타', label: '기타' }
+ ]
+
+ const countermeasureOptions = [
+ { value: '서면미교부_준법지원을 통한 현업부서 계몽활동 실시', label: '서면미교부_준법지원을 통한 현업부서 계몽활동 실시' },
+ { value: '서면미교부_계약만료일정 별도 관리 및 사전점검', label: '서면미교부_계약만료일정 별도 관리 및 사전점검' },
+ { value: '서면미교부_작업착수前 先작업합의서 체결토록 현업에 가이드', label: '서면미교부_작업착수前 先작업합의서 체결토록 현업에 가이드' },
+ { value: '부당가격인하_최종 협의된 견적서 접수/보관 必', label: '부당가격인하_최종 협의된 견적서 접수/보관 必' },
+ { value: '부당가격인하_합의서 체결시 \'자율적 의사결정\' 等 문구 삽입', label: '부당가격인하_합의서 체결시 \'자율적 의사결정\' 等 문구 삽입' },
+ { value: '부당가격인하_수의계약時 금액 협의과정에 대한 근거 확보 (회의록, 메일, 당초/변경 견적서 等)', label: '부당가격인하_수의계약時 금액 협의과정에 대한 근거 확보 (회의록, 메일, 당초/변경 견적서 等)' },
+ { value: '기타', label: '기타' }
+ ]
+
+ return (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="checklist">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-3 w-full">
+ <HelpCircle className="h-5 w-5" />
+ <span className="font-medium">하도급법 자율점검 체크리스트</span>
+ <Badge className={resultInfo.color}>
+ {resultInfo.label}
+ </Badge>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <Card>
+ <CardContent className="space-y-6 pt-6">
+ {/* 체크박스 */}
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={isEnabled}
+ onCheckedChange={(checked) => setIsEnabled(checked as boolean)}
+ disabled={readOnly}
+ />
+ <span className="text-sm font-medium">하도급법 자율점검 체크리스트 활성화</span>
+ </div>
+
+ {/* 점검결과 표시 */}
+ <div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
+ <div className="flex items-center gap-2">
+ {resultInfo.icon}
+ <Badge className={resultInfo.color}>
+ {resultInfo.label}
+ </Badge>
+ </div>
+ <div className="text-sm text-gray-600">
+ {resultInfo.description}
+ </div>
+ </div>
+
+ <Accordion type="multiple" defaultValue={["contract-document", "unfair-pricing"]} className="w-full">
+ {/* 1. 계약서면발급 */}
+ <AccordionItem value="contract-document">
+ <AccordionTrigger className="text-lg font-semibold">
+ 1. 계약서면발급
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-4 p-4">
+ <Alert>
+ <AlertDescription>
+ 본 계약에 해당하는 항목을 아래 안내사항에 따라 &apos;O&apos;인 경우 체크하세요.
+ </AlertDescription>
+ </Alert>
+
+ <div className="space-y-4">
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="workOrderBeforeStart"
+ checked={data.contractDocumentIssuance.workOrderBeforeStart}
+ onCheckedChange={(checked) => handleContractDocumentChange('workOrderBeforeStart', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="workOrderBeforeStart" className="text-sm font-medium">
+ (1) 작업 착수前 계약 서면을 발급하지 못하는 경우 작업지시서(선작업합의서)를 발급했는가?
+ </Label>
+ <p className="text-xs text-gray-500">
+ ※ 단가, 물량 등을 정하지 못하는 경우 정하는 기일을 기재
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="entrustmentDetails"
+ checked={data.contractDocumentIssuance.entrustmentDetails}
+ onCheckedChange={(checked) => handleContractDocumentChange('entrustmentDetails', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="entrustmentDetails" className="text-sm font-medium">
+ (2) 위탁일자와 위탁내용(품명, 수량 등)을 명기하였는가?
+ </Label>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="deliveryDetails"
+ checked={data.contractDocumentIssuance.deliveryDetails}
+ onCheckedChange={(checked) => handleContractDocumentChange('deliveryDetails', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="deliveryDetails" className="text-sm font-medium">
+ (3) 납품, 인도 또는 제공하는 시기 및 장소(납기 및 납품장소)를 명기하였는가?
+ </Label>
+ <p className="text-xs text-gray-500">
+ 예: 삼성의 검사완료(승인) 후 목적물 인도 등
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="inspectionMethod"
+ checked={data.contractDocumentIssuance.inspectionMethod}
+ onCheckedChange={(checked) => handleContractDocumentChange('inspectionMethod', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="inspectionMethod" className="text-sm font-medium">
+ (4) 검사의 방법 및 시기를 명기하였는가?
+ </Label>
+ <p className="text-xs text-gray-500">
+ 예: 작업완료 후 삼성담당자 입회하에 검사를 실시하고 10일 이내 검사결과 통보
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="subcontractPayment"
+ checked={data.contractDocumentIssuance.subcontractPayment}
+ onCheckedChange={(checked) => handleContractDocumentChange('subcontractPayment', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="subcontractPayment" className="text-sm font-medium">
+ (5) 하도급대금과 그 지급방법(현금, 어음 등) 및 지급기일을 명기하였는가?
+ </Label>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="materialProvision"
+ checked={data.contractDocumentIssuance.materialProvision}
+ onCheckedChange={(checked) => handleContractDocumentChange('materialProvision', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="materialProvision" className="text-sm font-medium">
+ (6) 원재료 등 제공 시 품명/수량/제공일/대가/대가 지급방법 및 기일을 명기하였는가?
+ </Label>
+ <p className="text-xs text-gray-500">
+ 해당사항 없을 시에도 기재로 간주
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="priceAdjustment"
+ checked={data.contractDocumentIssuance.priceAdjustment}
+ onCheckedChange={(checked) => handleContractDocumentChange('priceAdjustment', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="priceAdjustment" className="text-sm font-medium">
+ (7) 원재료 등 가격변동에 따른 대금 조정 요건/방법/절차를 명기하였는가?
+ </Label>
+ </div>
+ </div>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+
+ {/* 2. 부당하도급대금결정행위 */}
+ <AccordionItem value="unfair-pricing">
+ <AccordionTrigger className="text-lg font-semibold">
+ 2. 부당하도급대금결정행위
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-4 p-4">
+ <Alert>
+ <AlertDescription>
+ 본 계약에 해당하는 항목을 아래 안내사항에 따라 &apos;O&apos;인 경우 체크하세요.
+ <br />
+ <strong>※ &apos;X&apos; 항목에 다음 안내사항이 자동 표기됩니다:</strong>
+ </AlertDescription>
+ </Alert>
+
+ <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 space-y-2">
+ <h4 className="font-medium text-yellow-800">안내사항:</h4>
+ <ul className="text-sm text-yellow-700 space-y-1">
+ <li>• 단가 인하時 객관/타당한 근거에 의해 산출하고 협력사와 합의</li>
+ <li>• 최저가 경쟁입찰 후 입찰자와 대금인하 협상 불가</li>
+ <li>• 협력사에 발주량 등 거래조건에 착오를 일으키게 하거나 타 사업자 견적 또는 거짓 견적을 보여주는 등 기만하여 대금을 결정할 수 없음</li>
+ <li>• 정당한 이유 없이 일률적 비율로 단가 인하 불가</li>
+ <li>• 정당한 이유 없이 특정 사업자를 차별 취급 하도록 대금 결정 불가</li>
+ </ul>
+ </div>
+
+ <div className="space-y-4">
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="priceReductionWithBasis"
+ checked={data.unfairSubcontractPricing.priceReductionWithBasis}
+ onCheckedChange={(checked) => handleUnfairPricingChange('priceReductionWithBasis', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="priceReductionWithBasis" className="text-sm font-medium">
+ 단가 인하時 객관/타당한 근거에 의해 산출하고 협력사와 합의
+ </Label>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="noNegotiationAfterLowestBid"
+ checked={data.unfairSubcontractPricing.noNegotiationAfterLowestBid}
+ onCheckedChange={(checked) => handleUnfairPricingChange('noNegotiationAfterLowestBid', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="noNegotiationAfterLowestBid" className="text-sm font-medium">
+ 최저가 경쟁입찰 후 입찰자와 대금인하 협상 불가
+ </Label>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="noDeceptionInPricing"
+ checked={data.unfairSubcontractPricing.noDeceptionInPricing}
+ onCheckedChange={(checked) => handleUnfairPricingChange('noDeceptionInPricing', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="noDeceptionInPricing" className="text-sm font-medium">
+ 협력사에 발주량 등 거래조건에 착오를 일으키게 하거나 타 사업자 견적 또는 거짓 견적을 보여주는 등 기만하여 대금을 결정할 수 없음
+ </Label>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="noUniformPriceReduction"
+ checked={data.unfairSubcontractPricing.noUniformPriceReduction}
+ onCheckedChange={(checked) => handleUnfairPricingChange('noUniformPriceReduction', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="noUniformPriceReduction" className="text-sm font-medium">
+ 정당한 이유 없이 일률적 비율로 단가 인하 불가
+ </Label>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="noDiscriminatoryTreatment"
+ checked={data.unfairSubcontractPricing.noDiscriminatoryTreatment}
+ onCheckedChange={(checked) => handleUnfairPricingChange('noDiscriminatoryTreatment', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="noDiscriminatoryTreatment" className="text-sm font-medium">
+ 정당한 이유 없이 특정 사업자를 차별 취급 하도록 대금 결정 불가
+ </Label>
+ </div>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+
+ {/* 위반/위반의심 시 추가 정보 */}
+ {isViolationOrSuspected && (
+ <AccordionItem value="violation-details">
+ <AccordionTrigger className="text-lg font-semibold">
+ 위반/위반의심 상세 정보
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-4 p-4">
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ 점검결과가 위반 또는 위반의심인 경우 아래 정보를 필수로 입력해주세요.
+ </AlertDescription>
+ </Alert>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="responsibleDepartment">귀책부서 *</Label>
+ <Textarea
+ id="responsibleDepartment"
+ value={data.responsibleDepartment || ''}
+ onChange={(e) => handleFieldChange('responsibleDepartment', e.target.value)}
+ placeholder="귀책부서를 입력하세요"
+ rows={2}
+ disabled={!isEnabled || readOnly}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="cause">원인 *</Label>
+ <Select
+ value={data.cause || ''}
+ onValueChange={(value) => handleFieldChange('cause', value)}
+ disabled={!isEnabled || readOnly}
+ >
+ <SelectTrigger disabled={!isEnabled || readOnly}>
+ <SelectValue placeholder="원인을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {causeOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {data.cause === '기타' && (
+ <Textarea
+ value={data.causeOther || ''}
+ onChange={(e) => handleFieldChange('causeOther', e.target.value)}
+ placeholder="기타 원인을 입력하세요"
+ rows={2}
+ disabled={!isEnabled || readOnly}
+ />
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="countermeasure">대책 *</Label>
+ <Select
+ value={data.countermeasure || ''}
+ onValueChange={(value) => handleFieldChange('countermeasure', value)}
+ disabled={!isEnabled || readOnly}
+ >
+ <SelectTrigger disabled={!isEnabled || readOnly}>
+ <SelectValue placeholder="대책을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {countermeasureOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {data.countermeasure === '기타' && (
+ <Textarea
+ value={data.countermeasureOther || ''}
+ onChange={(e) => handleFieldChange('countermeasureOther', e.target.value)}
+ placeholder="기타 대책을 입력하세요"
+ rows={2}
+ disabled={!isEnabled || readOnly}
+ />
+ )}
+ </div>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ )}
+ </Accordion>
+
+ {/* 저장 버튼 */}
+ {!readOnly && (
+ <div className="flex justify-end pt-4 border-t">
+ <Button onClick={handleSave} className="flex items-center gap-2">
+ <Save className="h-4 w-4" />
+ 체크리스트 저장
+ </Button>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )
+}
diff --git a/lib/general-contracts_old/main/create-general-contract-dialog.tsx b/lib/general-contracts_old/main/create-general-contract-dialog.tsx
new file mode 100644
index 00000000..2c3fc8bc
--- /dev/null
+++ b/lib/general-contracts_old/main/create-general-contract-dialog.tsx
@@ -0,0 +1,413 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Plus } from "lucide-react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+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 { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+import { cn } from "@/lib/utils"
+import { createContract, getVendors, getProjects } from "@/lib/general-contracts/service"
+import {
+ GENERAL_CONTRACT_CATEGORIES,
+ GENERAL_CONTRACT_TYPES,
+ GENERAL_EXECUTION_METHODS
+} from "@/lib/general-contracts/types"
+import { useSession } from "next-auth/react"
+
+interface CreateContractForm {
+ contractNumber: string
+ name: string
+ category: string
+ type: string
+ executionMethod: string
+ vendorId: number | null
+ projectId: number | null
+ startDate: Date | undefined
+ endDate: Date | undefined
+ validityEndDate: Date | undefined
+ notes: string
+}
+
+export function CreateGeneralContractDialog() {
+ const router = useRouter()
+ const { data: session } = useSession()
+ const [open, setOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [vendors, setVendors] = React.useState<Array<{ id: number; vendorName: string; vendorCode: string | null }>>([])
+ const [projects, setProjects] = React.useState<Array<{ id: number; code: string; name: string; type: string }>>([])
+
+ const [form, setForm] = React.useState<CreateContractForm>({
+ contractNumber: '',
+ name: '',
+ category: '',
+ type: '',
+ executionMethod: '',
+ vendorId: null,
+ projectId: null,
+ startDate: undefined,
+ endDate: undefined,
+ validityEndDate: undefined,
+ notes: '',
+ })
+
+ // 업체 목록 조회
+ React.useEffect(() => {
+ const fetchVendors = async () => {
+ try {
+ const vendorList = await getVendors()
+ setVendors(vendorList)
+ } catch (error) {
+ console.error('Error fetching vendors:', error)
+ }
+ }
+ fetchVendors()
+ }, [])
+
+ // 프로젝트 목록 조회
+ React.useEffect(() => {
+ const fetchProjects = async () => {
+ try {
+ const projectList = await getProjects()
+ console.log(projectList)
+ setProjects(projectList)
+ } catch (error) {
+ console.error('Error fetching projects:', error)
+ }
+ }
+ fetchProjects()
+ }, [])
+
+ const handleSubmit = async () => {
+ // 필수 필드 검증
+ if (!form.name || !form.category || !form.type || !form.executionMethod ||
+ !form.vendorId || !form.startDate || !form.endDate) {
+ toast.error("필수 항목을 모두 입력해주세요.")
+ return
+ }
+
+ if (!form.validityEndDate) {
+ setForm(prev => ({ ...prev, validityEndDate: form.endDate }))
+ }
+
+ try {
+ setIsLoading(true)
+
+ const contractData = {
+ contractNumber: '',
+ name: form.name,
+ category: form.category,
+ type: form.type,
+ executionMethod: form.executionMethod,
+ projectId: form.projectId,
+ contractSourceType: 'manual',
+ vendorId: form.vendorId!,
+ startDate: form.startDate!.toISOString().split('T')[0],
+ endDate: form.endDate!.toISOString().split('T')[0],
+ validityEndDate: (form.validityEndDate || form.endDate!).toISOString().split('T')[0],
+ status: 'Draft',
+ registeredById: session?.user?.id || 1,
+ lastUpdatedById: session?.user?.id || 1,
+ notes: form.notes,
+ }
+
+ await createContract(contractData)
+
+ toast.success("새 계약이 생성되었습니다.")
+ setOpen(false)
+ resetForm()
+
+ // 상세 페이지로 이동
+ router.refresh()
+ } catch (error) {
+ console.error('Error creating contract:', error)
+ toast.error("계약 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const resetForm = () => {
+ setForm({
+ contractNumber: '',
+ name: '',
+ category: '',
+ type: '',
+ executionMethod: '',
+ vendorId: null,
+ projectId: null,
+ startDate: undefined,
+ endDate: undefined,
+ validityEndDate: undefined,
+ notes: '',
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={(newOpen) => {
+ setOpen(newOpen)
+ if (!newOpen) resetForm()
+ }}>
+ <DialogTrigger asChild>
+ <Button size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 신규등록
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>새 계약 등록</DialogTitle>
+ <DialogDescription>
+ 새로운 계약의 기본 정보를 입력하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid gap-4 py-4">
+ <div className="grid grid-cols-1 gap-4">
+ <div className="grid gap-2">
+ <Label htmlFor="name">계약명 *</Label>
+ <Input
+ id="name"
+ value={form.name}
+ onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
+ placeholder="계약명을 입력하세요"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-3 gap-4">
+ <div className="grid gap-2">
+ <Label htmlFor="category">계약구분 *</Label>
+ <Select value={form.category} onValueChange={(value) => setForm(prev => ({ ...prev, category: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {GENERAL_CONTRACT_CATEGORIES.map((category) => {
+ const categoryLabels = {
+ 'unit_price': '단가계약',
+ 'general': '일반계약',
+ 'sale': '매각계약'
+ }
+ return (
+ <SelectItem key={category} value={category}>
+ {category} - {categoryLabels[category as keyof typeof categoryLabels]}
+ </SelectItem>
+ )
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="grid gap-2">
+ <Label htmlFor="type">계약종류 *</Label>
+ <Select value={form.type} onValueChange={(value) => setForm(prev => ({ ...prev, type: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="계약종류 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {GENERAL_CONTRACT_TYPES.map((type) => {
+ const typeLabels = {
+ 'UP': '자재단가계약',
+ 'LE': '임대차계약',
+ 'IL': '개별운송계약',
+ 'AL': '연간운송계약',
+ 'OS': '외주용역계약',
+ 'OW': '도급계약',
+ 'IS': '검사계약',
+ 'LO': 'LOI',
+ 'FA': 'FA',
+ 'SC': '납품합의계약',
+ 'OF': '클레임상계계약',
+ 'AW': '사전작업합의',
+ 'AD': '사전납품합의',
+ 'AM': '설계계약',
+ 'SC_SELL': '폐기물매각계약'
+ }
+ return (
+ <SelectItem key={type} value={type}>
+ {type} - {typeLabels[type as keyof typeof typeLabels]}
+ </SelectItem>
+ )
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="grid gap-2">
+ <Label htmlFor="executionMethod">체결방식 *</Label>
+ <Select value={form.executionMethod} onValueChange={(value) => setForm(prev => ({ ...prev, executionMethod: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="체결방식 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {GENERAL_EXECUTION_METHODS.map((method) => (
+ <SelectItem key={method} value={method}>
+ {method}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <Label htmlFor="project">프로젝트</Label>
+ <Select value={form.projectId?.toString()} onValueChange={(value) => setForm(prev => ({ ...prev, projectId: parseInt(value) }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="프로젝트 선택 (선택사항)" />
+ </SelectTrigger>
+ <SelectContent>
+ {projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.name} ({project.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="grid gap-2">
+ <Label htmlFor="vendor">협력업체 *</Label>
+ <Select value={form.vendorId?.toString()} onValueChange={(value) => setForm(prev => ({ ...prev, vendorId: parseInt(value) }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="협력업체 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {vendors.map((vendor) => (
+ <SelectItem key={vendor.id} value={vendor.id.toString()}>
+ {vendor.vendorName} ({vendor.vendorCode})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="grid grid-cols-3 gap-4">
+ <div className="grid gap-2">
+ <Label>계약시작일 *</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "justify-start text-left font-normal",
+ !form.startDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {form.startDate ? format(form.startDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={form.startDate}
+ onSelect={(date) => setForm(prev => ({ ...prev, startDate: date }))}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="grid gap-2">
+ <Label>계약종료일 *</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "justify-start text-left font-normal",
+ !form.endDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {form.endDate ? format(form.endDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={form.endDate}
+ onSelect={(date) => setForm(prev => ({ ...prev, endDate: date }))}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="grid gap-2">
+ <Label>유효기간종료일</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "justify-start text-left font-normal",
+ !form.validityEndDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {form.validityEndDate ? format(form.validityEndDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={form.validityEndDate}
+ onSelect={(date) => setForm(prev => ({ ...prev, validityEndDate: date }))}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+ </div>
+ <div className="grid gap-2">
+ <Label htmlFor="notes">비고</Label>
+ <Textarea
+ id="notes"
+ value={form.notes}
+ onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))}
+ placeholder="비고사항을 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={handleSubmit}
+ disabled={isLoading}
+ >
+ {isLoading ? '생성 중...' : '생성'}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/general-contracts_old/main/general-contract-update-sheet.tsx b/lib/general-contracts_old/main/general-contract-update-sheet.tsx
new file mode 100644
index 00000000..54f4ae4e
--- /dev/null
+++ b/lib/general-contracts_old/main/general-contract-update-sheet.tsx
@@ -0,0 +1,401 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} 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 {
+ GENERAL_CONTRACT_CATEGORIES,
+ GENERAL_CONTRACT_TYPES,
+ GENERAL_EXECUTION_METHODS,
+} from "@/lib/general-contracts/types"
+import { updateContract } from "../service"
+import { GeneralContractListItem } from "./general-contracts-table-columns"
+import { useSession } from "next-auth/react"
+const updateContractSchema = z.object({
+ category: z.string().min(1, "계약구분을 선택해주세요"),
+ type: z.string().min(1, "계약종류를 선택해주세요"),
+ executionMethod: z.string().min(1, "체결방식을 선택해주세요"),
+ name: z.string().min(1, "계약명을 입력해주세요"),
+ startDate: z.string().min(1, "계약시작일을 선택해주세요"),
+ endDate: z.string().min(1, "계약종료일을 선택해주세요"),
+ validityEndDate: z.string().min(1, "유효기간종료일을 선택해주세요"),
+ contractScope: z.string().min(1, "계약확정범위를 선택해주세요"),
+ notes: z.string().optional(),
+ linkedRfqOrItb: z.string().optional(),
+ linkedPoNumber: z.string().optional(),
+ linkedBidNumber: z.string().optional(),
+})
+
+type UpdateContractFormData = z.infer<typeof updateContractSchema>
+
+interface GeneralContractUpdateSheetProps {
+ contract: GeneralContractListItem | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess?: () => void
+}
+
+export function GeneralContractUpdateSheet({
+ contract,
+ open,
+ onOpenChange,
+ onSuccess,
+}: GeneralContractUpdateSheetProps) {
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const session = useSession()
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+ const form = useForm<UpdateContractFormData>({
+ resolver: zodResolver(updateContractSchema),
+ defaultValues: {
+ category: "",
+ type: "",
+ executionMethod: "",
+ name: "",
+ startDate: "",
+ endDate: "",
+ validityEndDate: "",
+ contractScope: "",
+ notes: "",
+ linkedRfqOrItb: "",
+ linkedPoNumber: "",
+ linkedBidNumber: "",
+ },
+ })
+
+ // 계약확정범위에 따른 품목정보 필드 비활성화 여부
+ const watchedContractScope = form.watch("contractScope")
+ const isItemsDisabled = watchedContractScope === '단가' || watchedContractScope === '물량(실적)'
+
+ // 계약 데이터가 변경될 때 폼 초기화
+ React.useEffect(() => {
+ if (contract) {
+ console.log("Loading contract data:", contract)
+ const formData = {
+ category: contract.category || "",
+ type: contract.type || "",
+ executionMethod: contract.executionMethod || "",
+ name: contract.name || "",
+ startDate: contract.startDate || "",
+ endDate: contract.endDate || "",
+ validityEndDate: contract.validityEndDate || "",
+ contractScope: contract.contractScope || "",
+ notes: contract.notes || "",
+ linkedRfqOrItb: contract.linkedRfqOrItb || "",
+ linkedPoNumber: contract.linkedPoNumber || "",
+ linkedBidNumber: contract.linkedBidNumber || "",
+ }
+ console.log("Form data to reset:", formData)
+ form.reset(formData)
+ }
+ }, [contract, form])
+
+ const onSubmit = async (data: UpdateContractFormData) => {
+ if (!contract) return
+
+ try {
+ setIsSubmitting(true)
+
+ await updateContract(contract.id, {
+ category: data.category,
+ type: data.type,
+ executionMethod: data.executionMethod,
+ name: data.name,
+ startDate: data.startDate,
+ endDate: data.endDate,
+ validityEndDate: data.validityEndDate,
+ contractScope: data.contractScope,
+ notes: data.notes,
+ linkedRfqOrItb: data.linkedRfqOrItb,
+ linkedPoNumber: data.linkedPoNumber,
+ linkedBidNumber: data.linkedBidNumber,
+ vendorId: contract.vendorId,
+ lastUpdatedById: userId,
+ })
+
+ toast.success("계약 정보가 성공적으로 수정되었습니다.")
+ onOpenChange(false)
+ onSuccess?.()
+ } catch (error) {
+ console.error("Error updating contract:", error)
+ toast.error("계약 정보 수정 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[800px] sm:max-w-[800px] flex flex-col" style={{width: 800, maxWidth: 800, height: '100vh'}}>
+ <SheetHeader className="flex-shrink-0">
+ <SheetTitle>계약 정보 수정</SheetTitle>
+ <SheetDescription>
+ 계약의 기본 정보를 수정합니다. 변경사항은 즉시 저장됩니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-y-auto min-h-0">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 h-full">
+ <div className="grid gap-4 py-4">
+ {/* 계약구분 */}
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약구분 *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {GENERAL_CONTRACT_CATEGORIES.map((category) => {
+ const categoryLabels = {
+ 'unit_price': '단가계약',
+ 'general': '일반계약',
+ 'sale': '매각계약'
+ }
+ return (
+ <SelectItem key={category} value={category}>
+ {category} - {categoryLabels[category as keyof typeof categoryLabels]}
+ </SelectItem>
+ )})}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약종류 */}
+ <FormField
+ control={form.control}
+ name="type"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약종류 *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약종류를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {GENERAL_CONTRACT_TYPES.map((type) => {
+ const typeLabels = {
+ 'UP': '자재단가계약',
+ 'LE': '임대차계약',
+ 'IL': '개별운송계약',
+ 'AL': '연간운송계약',
+ 'OS': '외주용역계약',
+ 'OW': '도급계약',
+ 'IS': '검사계약',
+ 'LO': 'LOI',
+ 'FA': 'FA',
+ 'SC': '납품합의계약',
+ 'OF': '클레임상계계약',
+ 'AW': '사전작업합의',
+ 'AD': '사전납품합의',
+ 'AM': '설계계약',
+ 'SC_SELL': '폐기물매각계약'
+ }
+ return (
+ <SelectItem key={type} value={type}>
+ {type} - {typeLabels[type as keyof typeof typeLabels]}
+ </SelectItem>
+ )})}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 체결방식 */}
+ <FormField
+ control={form.control}
+ name="executionMethod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>체결방식 *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="체결방식을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {GENERAL_EXECUTION_METHODS.map((method) => {
+ const methodLabels = {
+ '전자계약': '전자계약',
+ '오프라인계약': '오프라인계약'
+ }
+ return (
+ <SelectItem key={method} value={method}>
+ {method} - {methodLabels[method as keyof typeof methodLabels]}
+ </SelectItem>
+ )})}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약명 */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="계약명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약시작일 */}
+ <FormField
+ control={form.control}
+ name="startDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약시작일 *</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약종료일 */}
+ <FormField
+ control={form.control}
+ name="endDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약종료일 *</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 유효기간종료일 */}
+ <FormField
+ control={form.control}
+ name="validityEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>유효기간종료일 *</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약확정범위 */}
+ <FormField
+ control={form.control}
+ name="contractScope"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약확정범위 *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약확정범위를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="단가">단가</SelectItem>
+ <SelectItem value="금액">금액</SelectItem>
+ <SelectItem value="물량(실적)">물량(실적)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ <p className="text-sm text-muted-foreground">
+ 해당 계약으로 확정되는 범위를 선택하세요.
+ </p>
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="notes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고를 입력하세요"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <SheetFooter className="flex-shrink-0 mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "수정 중..." : "수정"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/general-contracts_old/main/general-contracts-table-columns.tsx b/lib/general-contracts_old/main/general-contracts-table-columns.tsx
new file mode 100644
index 00000000..a08d8b81
--- /dev/null
+++ b/lib/general-contracts_old/main/general-contracts-table-columns.tsx
@@ -0,0 +1,571 @@
+"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
+} from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import { formatDate } from "@/lib/utils"
+
+// 일반계약 리스트 아이템 타입 정의
+export interface GeneralContractListItem {
+ id: number
+ contractNumber: string
+ revision: number
+ status: string
+ category: string
+ type: string
+ executionMethod: string
+ name: string
+ contractSourceType?: string
+ startDate: string
+ endDate: string
+ validityEndDate?: string
+ contractScope?: string
+ specificationType?: string
+ specificationManualText?: string
+ contractAmount?: number | string | null
+ totalAmount?: number | string | null
+ currency?: string
+ registeredAt: string
+ signedAt?: string
+ linkedPoNumber?: string
+ linkedRfqOrItb?: string
+ linkedBidNumber?: string
+ lastUpdatedAt: string
+ notes?: string
+ vendorId?: number
+ vendorName?: string
+ vendorCode?: string
+ projectId?: number
+ projectName?: string
+ projectCode?: string
+ managerName?: string
+ lastUpdatedByName?: string
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GeneralContractListItem> | null>>
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'Draft':
+ return 'outline'
+ case 'Request to Review':
+ case 'Confirm to Review':
+ return 'secondary'
+ case 'Contract Accept Request':
+ return 'default'
+ case 'Complete the Contract':
+ return 'default'
+ case 'Reject to Accept Contract':
+ case 'Contract Delete':
+ return 'destructive'
+ default:
+ return 'outline'
+ }
+}
+
+// 상태 텍스트 변환
+const getStatusText = (status: string) => {
+ switch (status) {
+ case 'Draft':
+ return '임시저장'
+ case 'Request to Review':
+ return '조건검토요청'
+ case 'Confirm to Review':
+ return '조건검토완료'
+ case 'Contract Accept Request':
+ return '계약승인요청'
+ case 'Complete the Contract':
+ return '계약체결'
+ case 'Reject to Accept Contract':
+ return '계약승인거절'
+ case 'Contract Delete':
+ return '계약폐기'
+ case 'PCR Request':
+ return 'PCR요청'
+ case 'VO Request':
+ return 'VO요청'
+ case 'PCR Accept':
+ return 'PCR승인'
+ case 'PCR Reject':
+ return 'PCR거절'
+ default:
+ return status
+ }
+}
+
+// 계약구분 텍스트 변환
+const getCategoryText = (category: string) => {
+ switch (category) {
+ case 'unit_price':
+ return '단가계약'
+ case 'general':
+ return '일반계약'
+ case 'sale':
+ return '매각계약'
+ default:
+ return category
+ }
+}
+
+// 계약종류 텍스트 변환
+const getTypeText = (type: string) => {
+ switch (type) {
+ case 'UP':
+ return '자재단가계약'
+ case 'LE':
+ return '임대차계약'
+ case 'IL':
+ return '개별운송계약'
+ case 'AL':
+ return '연간운송계약'
+ case 'OS':
+ return '외주용역계약'
+ case 'OW':
+ return '도급계약'
+ case 'IS':
+ return '검사계약'
+ case 'LO':
+ return 'LOI'
+ case 'FA':
+ return 'FA'
+ case 'SC':
+ return '납품합의계약'
+ case 'OF':
+ return '클레임상계계약'
+ case 'AW':
+ return '사전작업합의'
+ case 'AD':
+ return '사전납품합의'
+ case 'AM':
+ return '설계계약'
+ case 'SC_SELL':
+ return '폐기물매각계약'
+ default:
+ return type
+ }
+}
+
+// 체결방식 텍스트 변환
+const getExecutionMethodText = (method: string) => {
+ switch (method) {
+ case '전자계약':
+ return '전자계약'
+ case '오프라인계약':
+ return '오프라인계약'
+ default:
+ return method
+ }
+}
+
+// 업체선정방법 텍스트 변환
+const getcontractSourceTypeText = (method?: string) => {
+ if (!method) return '-'
+ switch (method) {
+ case 'estimate':
+ return '견적'
+ case 'bid':
+ return '입찰'
+ case 'manual':
+ return '자체생성'
+ default:
+ return method
+ }
+}
+
+// 금액 포맷팅
+const formatCurrency = (amount: string | number | null | undefined, currency = 'KRW') => {
+ if (!amount && amount !== 0) return '-'
+
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
+ if (isNaN(numAmount)) return '-'
+
+ // 통화 코드가 null이거나 유효하지 않은 경우 기본값 사용
+ const safeCurrency = currency && typeof currency === 'string' ? currency : 'USD'
+
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: safeCurrency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(numAmount)
+}
+
+export function getGeneralContractsColumns({ setRowAction }: GetColumnsProps): ColumnDef<GeneralContractListItem>[] {
+ 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,
+ },
+
+ // ░░░ 계약번호 ░░░
+ {
+ accessorKey: "contractNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약번호 (Rev.)" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.contractNumber}
+ {row.original.revision > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">
+ Rev.{row.original.revision}
+ </span>
+ )}
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "계약번호 (Rev.)" },
+ },
+
+ // ░░░ 계약상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {getStatusText(row.original.status)}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "계약상태" },
+ },
+
+ // ░░░ 계약명 ░░░
+ {
+ accessorKey: "name",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.name}>
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ {row.original.name}
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "계약명" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 계약 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "계약 정보",
+ columns: [
+ {
+ accessorKey: "category",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {getCategoryText(row.original.category)}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ {
+ accessorKey: "type",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약종류" />,
+ cell: ({ row }) => (
+ <Badge variant="secondary">
+ {getTypeText(row.original.type)}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "계약종류" },
+ },
+
+ {
+ accessorKey: "executionMethod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="체결방식" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {getExecutionMethodText(row.original.executionMethod)}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "체결방식" },
+ },
+
+ {
+ accessorKey: "contractSourceType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업체선정방법" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {getcontractSourceTypeText(row.original.contractSourceType)}
+ </Badge>
+ ),
+ size: 200,
+ meta: { excelHeader: "업체선정방법" },
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 협력업체 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "협력업체",
+ columns: [
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="협력업체명" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-medium">{row.original.vendorName || '-'}</span>
+ <span className="text-xs text-muted-foreground">
+ {row.original.vendorCode ? row.original.vendorCode : "-"}
+ </span>
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "협력업체명" },
+ },
+
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-medium">{row.original.projectName || '-'}</span>
+ <span className="text-xs text-muted-foreground">
+ {row.original.projectCode ? row.original.projectCode : "-"}
+ </span>
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "프로젝트명" },
+ },
+
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 기간 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "계약기간",
+ columns: [
+ {
+ id: "contractPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />,
+ cell: ({ row }) => {
+ const startDate = row.original.startDate
+ const endDate = row.original.endDate
+
+ 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 isExpired = now > new Date(endDate)
+
+ return (
+ <div className="text-xs">
+ <div className={`${isActive ? 'text-green-600 font-medium' : isExpired ? 'text-red-600' : 'text-gray-600'}`}>
+ {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ </div>
+ {isActive && (
+ <Badge variant="default" className="text-xs mt-1">진행중</Badge>
+ )}
+ {isExpired && (
+ <Badge variant="destructive" className="text-xs mt-1">만료</Badge>
+ )}
+ </div>
+ )
+ },
+ size: 200,
+ meta: { excelHeader: "계약기간" },
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 금액 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "금액 정보",
+ columns: [
+ {
+ accessorKey: "currency",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.currency || 'KRW'}</span>
+ ),
+ size: 60,
+ meta: { excelHeader: "통화" },
+ },
+
+ {
+ accessorKey: "contractAmount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약금액" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium">
+ {formatCurrency(row.original.contractAmount, row.original.currency)}
+ </span>
+ ),
+ size: 200,
+ meta: { excelHeader: "계약금액" },
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 담당자 및 관리 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "관리 정보",
+ columns: [
+ {
+ 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: "계약담당자" },
+ },
+
+ {
+ accessorKey: "registeredAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약등록일" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.registeredAt, "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약등록일" },
+ },
+
+ {
+ accessorKey: "signedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약체결일" />,
+ cell: ({ row }) => (
+ <span className="text-sm">
+ {row.original.signedAt ? formatDate(row.original.signedAt, "KR") : '-'}
+ </span>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약체결일" },
+ },
+
+ {
+ accessorKey: "linkedPoNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="연계 PO번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.linkedPoNumber || '-'}</span>
+ ),
+ size: 140,
+ meta: { excelHeader: "연계 PO번호" },
+ },
+
+ {
+ accessorKey: "lastUpdatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.lastUpdatedAt, "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "최종수정일" },
+ },
+
+ {
+ accessorKey: "lastUpdatedByName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.lastUpdatedByName || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "최종수정자" },
+ },
+ ]
+ },
+
+ // ░░░ 비고 ░░░
+ {
+ accessorKey: "notes",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[150px]" title={row.original.notes || ''}>
+ {row.original.notes || '-'}
+ </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">
+ {row.original.status !== 'Contract Delete' && (
+ <>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "update" })}>
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+}
diff --git a/lib/general-contracts_old/main/general-contracts-table-toolbar-actions.tsx b/lib/general-contracts_old/main/general-contracts-table-toolbar-actions.tsx
new file mode 100644
index 00000000..f16b759a
--- /dev/null
+++ b/lib/general-contracts_old/main/general-contracts-table-toolbar-actions.tsx
@@ -0,0 +1,124 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ Download, FileSpreadsheet,
+ Trash2,
+} from "lucide-react"
+import { deleteContract } from "../service"
+import { toast } from "sonner"
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { GeneralContractListItem } from "./general-contracts-table-columns"
+import { CreateGeneralContractDialog } from "./create-general-contract-dialog"
+
+interface GeneralContractsTableToolbarActionsProps {
+ table: Table<GeneralContractListItem>
+}
+
+export function GeneralContractsTableToolbarActions({ table }: GeneralContractsTableToolbarActionsProps) {
+ const [isExporting, setIsExporting] = React.useState(false)
+
+ // 선택된 계약들
+ const selectedContracts = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ }, [table.getFilteredSelectedRowModel().rows])
+
+ const handleExport = async () => {
+ try {
+ setIsExporting(true)
+ await exportTableToExcel(table, {
+ filename: "general-contracts",
+ excludeColumns: ["select", "actions"],
+ })
+ toast.success("계약 목록이 성공적으로 내보내졌습니다.")
+ } catch (error) {
+ toast.error("내보내기 중 오류가 발생했습니다.")
+ } finally {
+ setIsExporting(false)
+ }
+ }
+
+
+ const handleDelete = async () => {
+ if (selectedContracts.length === 0) {
+ toast.error("계약폐기할 계약을 선택해주세요.")
+ return
+ }
+
+ // // 계약폐기 확인
+ // const confirmed = window.confirm(
+ // `선택한 ${selectedContracts.length}개 계약을 폐기하시겠습니까?\n계약폐기 후에는 복구할 수 없습니다.`
+ // )
+
+ // if (!confirmed) return
+
+ try {
+ // 선택된 모든 계약을 폐기 처리
+ const deletePromises = selectedContracts.map(contract =>
+ deleteContract(contract.id)
+ )
+
+ await Promise.all(deletePromises)
+
+ toast.success(`${selectedContracts.length}개 계약이 폐기되었습니다.`)
+
+ // 테이블 새로고침
+ } catch (error) {
+ console.error('Error deleting contracts:', error)
+ toast.error("계약폐기 중 오류가 발생했습니다.")
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 신규 등록 */}
+ <CreateGeneralContractDialog />
+
+ {/* 계약폐기 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDelete}
+ disabled={selectedContracts.length === 0}
+ className="text-red-600 hover:text-red-700 hover:bg-red-50"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 계약폐기
+ </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>
+ )
+}
diff --git a/lib/general-contracts_old/main/general-contracts-table.tsx b/lib/general-contracts_old/main/general-contracts-table.tsx
new file mode 100644
index 00000000..e4c96ee3
--- /dev/null
+++ b/lib/general-contracts_old/main/general-contracts-table.tsx
@@ -0,0 +1,217 @@
+"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 { getGeneralContractsColumns, GeneralContractListItem } from "./general-contracts-table-columns"
+import { getGeneralContracts, getGeneralContractStatusCounts } from "@/lib/general-contracts/service"
+import { GeneralContractsTableToolbarActions } from "./general-contracts-table-toolbar-actions"
+import { GeneralContractUpdateSheet } from "./general-contract-update-sheet"
+import {
+ GENERAL_EXECUTION_METHODS
+} from "@/lib/general-contracts/types"
+
+// 상태 라벨 매핑
+const contractStatusLabels = {
+ 'Draft': '임시저장',
+ 'Request to Review': '조건검토요청',
+ 'Confirm to Review': '조건검토완료',
+ 'Contract Accept Request': '계약승인요청',
+ 'Complete the Contract': '계약체결',
+ 'Reject to Accept Contract': '계약승인거절',
+ 'Contract Delete': '계약폐기',
+ 'PCR Request': 'PCR요청',
+ 'VO Request': 'VO요청',
+ 'PCR Accept': 'PCR승인',
+ 'PCR Reject': 'PCR거절'
+}
+
+// 계약구분 라벨 매핑
+const contractCategoryLabels = {
+ '단가계약': '단가계약',
+ '일반계약': '일반계약',
+ '매각계약': '매각계약'
+}
+
+// 계약종류 라벨 매핑
+const contractTypeLabels = {
+ 'UP': '자재단가계약',
+ 'LE': '임대차계약',
+ 'IL': '개별운송계약',
+ 'AL': '연간운송계약',
+ 'OS': '외주용역계약',
+ 'OW': '도급계약',
+ 'IS': '검사계약',
+ 'LO': 'LOI',
+ 'FA': 'FA',
+ 'SC': '납품합의계약',
+ 'OF': '클레임상계계약',
+ 'AW': '사전작업합의',
+ 'AD': '사전납품합의',
+ 'AM': '설계계약',
+ 'SC_SELL': '폐기물매각계약'
+}
+
+interface GeneralContractsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getGeneralContracts>>,
+ Awaited<ReturnType<typeof getGeneralContractStatusCounts>>
+ ]
+ >
+}
+
+export function GeneralContractsTable({ promises }: GeneralContractsTableProps) {
+ const [{ data, pageCount }, statusCounts] = React.use(promises)
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<GeneralContractListItem> | null>(null)
+ const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false)
+ const [selectedContract, setSelectedContract] = React.useState<GeneralContractListItem | null>(null)
+
+ console.log(data, "data")
+
+ const router = useRouter()
+
+ const columns = React.useMemo(
+ () => getGeneralContractsColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // rowAction 변경 감지하여 해당 액션 처리
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedContract(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/general-contracts/${rowAction.row.original.id}`)
+ break
+ case "update":
+ // 수정 시트 열기
+ setSelectedContract(rowAction.row.original)
+ setUpdateSheetOpen(true)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction, router])
+
+ const filterFields: DataTableFilterField<GeneralContractListItem>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<GeneralContractListItem>[] = [
+ { id: "name", label: "계약명", type: "text" },
+ { id: "contractNumber", label: "계약번호", type: "text" },
+ { id: "vendorName", label: "협력업체명", type: "text" },
+ { id: "managerName", label: "계약담당자", type: "text" },
+ {
+ id: "status",
+ label: "계약상태",
+ type: "multi-select",
+ options: Object.entries(contractStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ count: statusCounts[value] || 0,
+ })),
+ },
+ {
+ id: "category",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractCategoryLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "type",
+ label: "계약종류",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "executionMethod",
+ label: "체결방식",
+ type: "select",
+ options: GENERAL_EXECUTION_METHODS.map(value => ({
+ label: value,
+ value: value,
+ })),
+ },
+ {
+ id: "contractSourceType",
+ label: "업체선정방법",
+ type: "select",
+ options: [
+ { label: "estimate", value: "견적" },
+ { label: "bid", value: "입찰" },
+ { label: "manual", value: "자체생성" },
+ ],
+ },
+ { id: "registeredAt", label: "계약등록일", type: "date" },
+ { id: "signedAt", label: "계약체결일", type: "date" },
+ { id: "lastUpdatedAt", label: "최종수정일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "registeredAt", 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="generalContractsTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <GeneralContractsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <GeneralContractUpdateSheet
+ contract={selectedContract}
+ open={updateSheetOpen}
+ onOpenChange={setUpdateSheetOpen}
+ onSuccess={() => {
+ // 테이블 새로고침 또는 상태 업데이트
+ window.location.reload()
+ }}
+ />
+ </>
+ )
+}
diff --git a/lib/general-contracts_old/service.ts b/lib/general-contracts_old/service.ts
new file mode 100644
index 00000000..2422706a
--- /dev/null
+++ b/lib/general-contracts_old/service.ts
@@ -0,0 +1,1933 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { eq, and, or, desc, asc, count, ilike, SQL, gte, lte, lt, like, sql } from 'drizzle-orm'
+import db from '@/db/db'
+import path from 'path'
+import { promises as fs } from 'fs'
+import { generalContracts, generalContractItems, generalContractAttachments } from '@/db/schema/generalContract'
+import { contracts, contractItems, contractEnvelopes, contractSigners } from '@/db/schema/contract'
+import { basicContract, basicContractTemplates } from '@/db/schema/basicContractDocumnet'
+import { vendors } from '@/db/schema/vendors'
+import { users } from '@/db/schema/users'
+import { projects } from '@/db/schema/projects'
+import { items } from '@/db/schema/items'
+import { filterColumns } from '@/lib/filter-columns'
+import { saveDRMFile } from '@/lib/file-stroage'
+import { decryptWithServerAction } from '@/components/drm/drmUtils'
+import { saveBuffer } from '@/lib/file-stroage'
+import { v4 as uuidv4 } from 'uuid'
+import { GetGeneralContractsSchema } from './validation'
+import { sendEmail } from '../mail/sendEmail'
+
+export async function getGeneralContracts(input: GetGeneralContractsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ console.log(input.filters)
+ console.log(input.sort)
+
+ // ✅ 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: generalContracts,
+ filters: input.filters as any,
+ joinOperator: input.joinOperator || 'and',
+ })
+ }
+
+ // ✅ 2) 기본 필터 조건들
+ const basicConditions: SQL<unknown>[] = []
+
+ if (input.contractNumber) {
+ basicConditions.push(ilike(generalContracts.contractNumber, `%${input.contractNumber}%`))
+ }
+
+ if (input.name) {
+ basicConditions.push(ilike(generalContracts.name, `%${input.name}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(generalContracts.status, status)))!
+ )
+ }
+
+ if (input.category && input.category.length > 0) {
+ basicConditions.push(
+ or(...input.category.map(category => eq(generalContracts.category, category)))!
+ )
+ }
+
+ if (input.type && input.type.length > 0) {
+ basicConditions.push(
+ or(...input.type.map(type => eq(generalContracts.type, type)))!
+ )
+ }
+
+ if (input.executionMethod && input.executionMethod.length > 0) {
+ basicConditions.push(
+ or(...input.executionMethod.map(method => eq(generalContracts.executionMethod, method)))!
+ )
+ }
+
+ if (input.contractSourceType && input.contractSourceType.length > 0) {
+ basicConditions.push(
+ or(...input.contractSourceType.map(method => eq(generalContracts.contractSourceType, method)))!
+ )
+ }
+
+ if (input.vendorId && input.vendorId > 0) {
+ basicConditions.push(eq(generalContracts.vendorId, input.vendorId))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(ilike(users.name, `%${input.managerName}%`))
+ }
+
+ // 날짜 필터들
+ if (input.registeredAtFrom) {
+ basicConditions.push(gte(generalContracts.registeredAt, new Date(input.registeredAtFrom)))
+ }
+ if (input.registeredAtTo) {
+ basicConditions.push(lte(generalContracts.registeredAt, new Date(input.registeredAtTo)))
+ }
+
+ if (input.signedAtFrom) {
+ basicConditions.push(gte(generalContracts.signedAt, new Date(input.signedAtFrom)))
+ }
+ if (input.signedAtTo) {
+ basicConditions.push(lte(generalContracts.signedAt, new Date(input.signedAtTo)))
+ }
+
+ if (input.startDateFrom) {
+ basicConditions.push(gte(generalContracts.startDate, new Date(input.startDateFrom)))
+ }
+ if (input.startDateTo) {
+ basicConditions.push(lte(generalContracts.startDate, new Date(input.startDateTo)))
+ }
+
+ if (input.endDateFrom) {
+ basicConditions.push(gte(generalContracts.endDate, new Date(input.endDateFrom)))
+ }
+ if (input.endDateTo) {
+ basicConditions.push(lte(generalContracts.endDate, new Date(input.endDateTo)))
+ }
+
+ // 금액 필터들
+ if (input.contractAmountMin) {
+ basicConditions.push(gte(generalContracts.contractAmount, parseFloat(input.contractAmountMin)))
+ }
+ if (input.contractAmountMax) {
+ basicConditions.push(lte(generalContracts.contractAmount, parseFloat(input.contractAmountMax)))
+ }
+
+ 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(generalContracts.contractNumber, s),
+ ilike(generalContracts.name, s),
+ ilike(generalContracts.notes, s),
+ ilike(vendors.vendorName, s),
+ ilike(users.name, s),
+ ilike(generalContracts.linkedPoNumber, s),
+ ilike(generalContracts.linkedRfqOrItb, s),
+ ilike(generalContracts.linkedBidNumber, 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(generalContracts)
+ .leftJoin(vendors, eq(generalContracts.vendorId, vendors.id))
+ .leftJoin(users, eq(generalContracts.registeredById, users.id))
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ console.log("Total contracts:", total)
+
+ // ✅ 6) 정렬 및 페이징
+ const orderByColumns: any[] = []
+
+ for (const sort of input.sort) {
+ const column = sort.id
+
+ // generalContracts 테이블의 컬럼들
+ if (column in generalContracts) {
+ const contractColumn = generalContracts[column as keyof typeof generalContracts]
+ orderByColumns.push(sort.desc ? desc(contractColumn) : asc(contractColumn))
+ }
+ // vendors 테이블의 컬럼들
+ else if (column === 'vendorName' || column === 'vendorCode') {
+ const vendorColumn = vendors[column as keyof typeof vendors]
+ orderByColumns.push(sort.desc ? desc(vendorColumn) : asc(vendorColumn))
+ }
+ // users 테이블의 컬럼들
+ else if (column === 'managerName' || column === 'lastUpdatedByName') {
+ const userColumn = users.name
+ orderByColumns.push(sort.desc ? desc(userColumn) : asc(userColumn))
+ }
+ }
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(generalContracts.registeredAt))
+ }
+
+ // ✅ 7) 메인 쿼리
+ const data = await db
+ .select({
+ id: generalContracts.id,
+ contractNumber: generalContracts.contractNumber,
+ revision: generalContracts.revision,
+ status: generalContracts.status,
+ category: generalContracts.category,
+ type: generalContracts.type,
+ executionMethod: generalContracts.executionMethod,
+ name: generalContracts.name,
+ contractSourceType: generalContracts.contractSourceType,
+ startDate: generalContracts.startDate,
+ endDate: generalContracts.endDate,
+ validityEndDate: generalContracts.validityEndDate,
+ contractScope: generalContracts.contractScope,
+ specificationType: generalContracts.specificationType,
+ specificationManualText: generalContracts.specificationManualText,
+ contractAmount: generalContracts.contractAmount,
+ totalAmount: generalContracts.totalAmount,
+ currency: generalContracts.currency,
+ registeredAt: generalContracts.registeredAt,
+ signedAt: generalContracts.signedAt,
+ linkedRfqOrItb: generalContracts.linkedRfqOrItb,
+ linkedPoNumber: generalContracts.linkedPoNumber,
+ linkedBidNumber: generalContracts.linkedBidNumber,
+ lastUpdatedAt: generalContracts.lastUpdatedAt,
+ notes: generalContracts.notes,
+ // Vendor info
+ vendorId: generalContracts.vendorId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ // Project info
+ projectId: generalContracts.projectId,
+ projectName: projects.name,
+ projectCode: projects.code,
+ // User info
+ managerName: users.name,
+ lastUpdatedByName: users.name,
+ })
+ .from(generalContracts)
+ .leftJoin(vendors, eq(generalContracts.vendorId, vendors.id))
+ .leftJoin(users, eq(generalContracts.registeredById, users.id))
+ .leftJoin(projects, eq(generalContracts.projectId, projects.id))
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getGeneralContracts:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+export async function getContractById(id: number) {
+ try {
+ // ID 유효성 검사
+ if (!id || isNaN(id) || id <= 0) {
+ throw new Error('Invalid contract ID')
+ }
+
+ const contract = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, id))
+ .limit(1)
+
+ if (!contract.length) {
+ throw new Error('Contract not found')
+ }
+
+ // Get contract items
+ const items = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, id))
+
+ // Get contract attachments
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(eq(generalContractAttachments.contractId, id))
+
+ // Get vendor info
+ const vendor = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, contract[0].vendorId))
+ .limit(1)
+
+ // Get project info
+ const project = contract[0].projectId ? await db
+ .select()
+ .from(projects)
+ .where(eq(projects.id, contract[0].projectId))
+ .limit(1) : null
+
+ // Get manager info
+ const manager = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, contract[0].registeredById))
+ .limit(1)
+
+ return {
+ ...contract[0],
+ contractItems: items,
+ attachments,
+ vendor: vendor[0] || null,
+ vendorCode: vendor[0]?.vendorCode || null,
+ vendorName: vendor[0]?.vendorName || null,
+ project: project ? project[0] : null,
+ projectName: project ? project[0].name : null,
+ projectCode: project ? project[0].code : null,
+ manager: manager[0] || null
+ }
+ } catch (error) {
+ console.error('Error fetching contract by ID:', error)
+ throw new Error('Failed to fetch contract')
+ }
+}
+
+export async function getContractBasicInfo(id: number) {
+ try {
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, id))
+ .limit(1)
+
+ if (!contract) {
+ return null
+ }
+
+ // JSON 필드를 문자열에서 객체로 변환하여 클라이언트에서 사용하기 쉽게 만듭니다.
+ // Drizzle ORM이 JSONB 타입을 처리하지만, 명확성을 위해 명시적으로 파싱하는 것이 좋습니다.
+ const parsedContract = {
+ ...contract,
+ warrantyPeriod: contract.warrantyPeriod as any,
+ paymentBeforeDelivery: contract.paymentBeforeDelivery as any,
+ paymentAfterDelivery: contract.paymentAfterDelivery as any,
+ contractEstablishmentConditions: contract.contractEstablishmentConditions as any,
+ mandatoryDocuments: contract.mandatoryDocuments as any,
+ contractTerminationConditions: contract.contractTerminationConditions as any,
+ }
+
+ // 품목정보 총합 계산 로직 (기존 코드와 동일)
+ const contractItems = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, id))
+
+ let calculatedContractAmount = null
+ if (contractItems && contractItems.length > 0) {
+ calculatedContractAmount = contractItems.reduce((sum, item) => {
+ const amount = parseFloat(item.contractAmount || '0')
+ return sum + amount
+ }, 0)
+ }
+
+ return {
+ ...parsedContract,
+ contractAmount: calculatedContractAmount,
+ }
+
+ } catch (error) {
+ console.error('Error getting contract basic info:', error)
+ throw new Error('Failed to fetch contract basic info')
+ }
+}
+
+export async function createContract(data: Record<string, unknown>) {
+ try {
+ // 계약번호 자동 생성
+ // TODO: 구매 발주담당자 코드 필요 - 파라미터 추가
+ const rawUserId = data.registeredById
+ const userId = (rawUserId && !isNaN(Number(rawUserId))) ? String(rawUserId) : undefined
+ const contractNumber = await generateContractNumber(
+ userId,
+ data.type as string
+ )
+
+ const [newContract] = await db
+ .insert(generalContracts)
+ .values({
+ contractNumber: contractNumber,
+ revision: 0,
+ // contractSourceType: data.contractSourceType || 'manual',
+ status: data.status || 'Draft',
+ category: data.category as string,
+ type: data.type as string,
+ executionMethod: data.executionMethod as string,
+ name: data.name as string,
+ vendorId: data.vendorId as number,
+ projectId: data.projectId as number,
+ startDate: data.startDate as string,
+ endDate: data.endDate as string,
+ validityEndDate: data.validityEndDate as string,
+ linkedRfqOrItb: data.linkedRfqOrItb as string,
+ linkedPoNumber: data.linkedPoNumber as string,
+ linkedBidNumber: data.linkedBidNumber as string,
+ contractScope: data.contractScope as string,
+ warrantyPeriod: data.warrantyPeriod || {},
+ specificationType: data.specificationType as string,
+ specificationManualText: data.specificationManualText as string,
+ unitPriceType: data.unitPriceType as string,
+ contractAmount: data.contractAmount as number,
+ currency: data.currency as string,
+ paymentBeforeDelivery: data.paymentBeforeDelivery || {},
+ paymentDelivery: data.paymentDelivery as string,
+ paymentAfterDelivery: data.paymentAfterDelivery || {},
+ paymentTerm: data.paymentTerm as string,
+ taxType: data.taxType as string,
+ liquidatedDamages: data.liquidatedDamages as number,
+ liquidatedDamagesPercent: data.liquidatedDamagesPercent as number,
+ deliveryType: data.deliveryType as string,
+ deliveryTerm: data.deliveryTerm as string,
+ shippingLocation: data.shippingLocation as string,
+ dischargeLocation: data.dischargeLocation as string,
+ contractDeliveryDate: data.contractDeliveryDate as string,
+ contractEstablishmentConditions: data.contractEstablishmentConditions || {},
+ interlockingSystem: data.interlockingSystem as string,
+ mandatoryDocuments: data.mandatoryDocuments || {},
+ contractTerminationConditions: data.contractTerminationConditions || {},
+ terms: data.terms || {},
+ complianceChecklist: data.complianceChecklist || {},
+ communicationChannels: data.communicationChannels || {},
+ locations: data.locations || {},
+ fieldServiceRates: data.fieldServiceRates || {},
+ offsetDetails: data.offsetDetails || {},
+ totalAmount: data.totalAmount as number,
+ availableBudget: data.availableBudget as number,
+ registeredById: data.registeredById as number,
+ lastUpdatedById: data.lastUpdatedById as number,
+ notes: data.notes as string,
+ })
+ .returning()
+ console.log(newContract,"newContract")
+
+
+ revalidatePath('/general-contracts')
+ return newContract
+ } catch (error) {
+ console.error('Error creating contract:', error)
+ throw new Error('Failed to create contract')
+ }
+}
+
+export async function updateContractBasicInfo(id: number, data: Record<string, unknown>, userId: number) {
+ try {
+ // 업데이트할 데이터 정리
+ // 클라이언트에서 전송된 formData를 그대로 사용합니다.
+ const {
+ specificationType,
+ specificationManualText,
+ unitPriceType,
+ warrantyPeriod,
+ currency,
+ linkedPoNumber,
+ linkedBidNumber,
+ notes,
+ paymentBeforeDelivery,
+ paymentDelivery,
+ paymentAfterDelivery,
+ paymentTerm,
+ taxType,
+ liquidatedDamages,
+ liquidatedDamagesPercent,
+ deliveryType,
+ deliveryTerm,
+ shippingLocation,
+ dischargeLocation,
+ contractDeliveryDate,
+ contractEstablishmentConditions,
+ interlockingSystem,
+ mandatoryDocuments,
+ contractTerminationConditions,
+ } = data
+
+ // 계약금액 자동 집계 로직
+ const contractItems = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, id))
+
+ let calculatedContractAmount: number | null = null
+ if (contractItems && contractItems.length > 0) {
+ calculatedContractAmount = contractItems.reduce((sum, item) => {
+ const amount = parseFloat(item.contractAmount || '0')
+ return sum + amount
+ }, 0)
+ }
+
+ // 데이터 타입 변환 및 검증
+ const convertToNumberOrNull = (value: unknown): number | null => {
+ if (value === null || value === undefined || value === '' || value === 'false') {
+ return null
+ }
+ const num = typeof value === 'string' ? parseFloat(value) : Number(value)
+ return isNaN(num) ? null : num
+ }
+
+ // 날짜 필드에서 빈 문자열을 null로 변환
+ const convertEmptyStringToNull = (value: unknown): string | null => {
+ return (value === '' || value === undefined) ? null : value as string
+ }
+
+ // 업데이트할 데이터 객체 생성
+ const updateData: Record<string, unknown> = {
+ specificationType,
+ specificationManualText,
+ unitPriceType,
+ warrantyPeriod, // JSON 필드
+ currency,
+ linkedPoNumber,
+ linkedBidNumber,
+ notes,
+ paymentBeforeDelivery, // JSON 필드
+ paymentDelivery: convertToNumberOrNull(paymentDelivery),
+ paymentAfterDelivery, // JSON 필드
+ paymentTerm,
+ taxType,
+ liquidatedDamages: convertToNumberOrNull(liquidatedDamages),
+ liquidatedDamagesPercent: convertToNumberOrNull(liquidatedDamagesPercent),
+ deliveryType,
+ deliveryTerm,
+ shippingLocation,
+ dischargeLocation,
+ contractDeliveryDate: convertEmptyStringToNull(contractDeliveryDate),
+ contractEstablishmentConditions, // JSON 필드
+ interlockingSystem,
+ mandatoryDocuments, // JSON 필드
+ contractTerminationConditions, // JSON 필드
+ contractAmount: calculatedContractAmount || 0,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId,
+ }
+
+ // DB에 업데이트 실행
+ const [updatedContract] = await db
+ .update(generalContracts)
+ .set(updateData)
+ .where(eq(generalContracts.id, id))
+ .returning()
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${id}`)
+ return updatedContract
+ } catch (error) {
+ console.error('Error updating contract basic info:', error)
+ throw new Error('Failed to update contract basic info')
+ }
+}
+
+// 품목정보 조회
+export async function getContractItems(contractId: number) {
+ try {
+ const items = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, contractId))
+ .orderBy(asc(generalContractItems.id))
+
+ return items
+ } catch (error) {
+ console.error('Error getting contract items:', error)
+ throw new Error('Failed to get contract items')
+ }
+}
+
+// 품목정보 생성
+export async function createContractItem(contractId: number, itemData: Record<string, unknown>) {
+ try {
+ const [newItem] = await db
+ .insert(generalContractItems)
+ .values({
+ contractId,
+ itemCode: itemData.itemCode as string,
+ itemInfo: itemData.itemInfo as string,
+ specification: itemData.specification as string,
+ quantity: itemData.quantity as number,
+ quantityUnit: itemData.quantityUnit as string,
+ contractDeliveryDate: itemData.contractDeliveryDate as string,
+ contractUnitPrice: itemData.contractUnitPrice as number,
+ contractAmount: itemData.contractAmount as number,
+ contractCurrency: itemData.contractCurrency as string,
+ })
+ .returning()
+
+ // 계약금액 자동 업데이트
+ await updateContractAmount(contractId)
+
+ revalidatePath('/general-contracts')
+ return newItem
+ } catch (error) {
+ console.error('Error creating contract item:', error)
+ throw new Error('Failed to create contract item')
+ }
+}
+
+// 품목정보 업데이트
+export async function updateContractItem(itemId: number, itemData: Record<string, unknown>) {
+ try {
+ const [updatedItem] = await db
+ .update(generalContractItems)
+ .set({
+ itemCode: itemData.itemCode as string,
+ itemInfo: itemData.itemInfo as string,
+ specification: itemData.specification as string,
+ quantity: itemData.quantity as number,
+ quantityUnit: itemData.quantityUnit as string,
+ contractDeliveryDate: itemData.contractDeliveryDate as string,
+ contractUnitPrice: itemData.contractUnitPrice as number,
+ contractAmount: itemData.contractAmount as number,
+ contractCurrency: itemData.contractCurrency as string,
+ updatedAt: new Date()
+ })
+ .where(eq(generalContractItems.id, itemId))
+ .returning()
+
+ // 계약금액 자동 업데이트
+ await updateContractAmount(updatedItem.contractId)
+
+ revalidatePath('/general-contracts')
+ return updatedItem
+ } catch (error) {
+ console.error('Error updating contract item:', error)
+ throw new Error('Failed to update contract item')
+ }
+}
+
+// 품목정보 삭제
+export async function deleteContractItem(itemId: number) {
+ try {
+ // 삭제 전 계약 ID 조회
+ const [item] = await db
+ .select({ contractId: generalContractItems.contractId })
+ .from(generalContractItems)
+ .where(eq(generalContractItems.id, itemId))
+ .limit(1)
+
+ if (!item) {
+ throw new Error('Contract item not found')
+ }
+
+ await db
+ .delete(generalContractItems)
+ .where(eq(generalContractItems.id, itemId))
+
+ // 계약금액 자동 업데이트
+ await updateContractAmount(item.contractId)
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error deleting contract item:', error)
+ throw new Error('Failed to delete contract item')
+ }
+}
+
+// 품목정보 일괄 업데이트 (기존 함수 개선)
+export async function updateContractItems(contractId: number, items: Record<string, unknown>[]) {
+ try {
+ // 기존 품목 삭제
+ await db
+ .delete(generalContractItems)
+ .where(eq(generalContractItems.contractId, contractId))
+
+ // 새 품목 추가
+ if (items && items.length > 0) {
+ await db
+ .insert(generalContractItems)
+ .values(
+ items.map((item: Record<string, unknown>) => ({
+ contractId,
+ itemCode: item.itemCode as string,
+ itemInfo: item.itemInfo as string,
+ specification: item.specification as string,
+ quantity: item.quantity as number,
+ quantityUnit: item.quantityUnit as string,
+ totalWeight: item.totalWeight as number,
+ weightUnit: item.weightUnit as string,
+ contractDeliveryDate: item.contractDeliveryDate as string,
+ contractUnitPrice: item.contractUnitPrice as number,
+ contractAmount: item.contractAmount as number,
+ contractCurrency: item.contractCurrency as string,
+ }))
+ )
+ }
+
+ // 계약금액 자동 업데이트
+ await updateContractAmount(contractId)
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error updating contract items:', error)
+ throw new Error('Failed to update contract items')
+ }
+}
+
+// 계약금액 자동 업데이트 헬퍼 함수
+async function updateContractAmount(contractId: number) {
+ try {
+ const items = await db
+ .select({ contractAmount: generalContractItems.contractAmount })
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, contractId))
+
+ let calculatedContractAmount: number | null = null
+ if (items && items.length > 0) {
+ calculatedContractAmount = items.reduce((sum, item) => {
+ const amount = parseFloat(String(item.contractAmount || '0'))
+ return sum + amount
+ }, 0)
+ }
+
+ // 계약 테이블의 contractAmount 업데이트
+ await db
+ .update(generalContracts)
+ .set({
+ contractAmount: calculatedContractAmount || 0,
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, contractId))
+ } catch (error) {
+ console.error('Error updating contract amount:', error)
+ throw new Error('Failed to update contract amount')
+ }
+}
+
+export async function updateSubcontractChecklist(contractId: number, checklistData: Record<string, unknown>) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ complianceChecklist: checklistData,
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error updating subcontract checklist:', error)
+ throw new Error('Failed to update subcontract checklist')
+ }
+}
+
+export async function getSubcontractChecklist(contractId: number) {
+ try {
+ const result = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (result.length === 0) {
+ return { success: false, error: '계약을 찾을 수 없습니다.' }
+ }
+
+ const contract = result[0]
+ const checklistData = contract.complianceChecklist as any
+
+ return {
+ success: true,
+ enabled: !!checklistData,
+ data: checklistData || {}
+ }
+ } catch (error) {
+ console.error('Error getting subcontract checklist:', error)
+ return { success: false, error: '하도급 체크리스트 조회에 실패했습니다.' }
+ }
+}
+
+export async function getBasicInfo(contractId: number) {
+ try {
+ const result = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (result.length === 0) {
+ return { success: false, error: '계약을 찾을 수 없습니다.' }
+ }
+
+ const contract = result[0]
+ return {
+ success: true,
+ enabled: true, // basic-info는 항상 활성화
+ data: {
+ // 기본 정보
+ contractNumber: contract.contractNumber,
+ contractName: contract.name,
+ vendorId: contract.vendorId,
+ vendorName: contract.vendorName,
+ projectName: contract.projectName,
+ contractType: contract.type,
+ contractStatus: contract.status,
+ startDate: contract.startDate,
+ endDate: contract.endDate,
+ contractAmount: contract.contractAmount,
+ currency: contract.currency,
+ description: contract.description,
+ specificationType: contract.specificationType,
+ specificationManualText: contract.specificationManualText,
+ unitPriceType: contract.unitPriceType,
+ warrantyPeriod: contract.warrantyPeriod,
+ linkedPoNumber: contract.linkedPoNumber,
+ linkedBidNumber: contract.linkedBidNumber,
+ notes: contract.notes,
+
+ // 지급/인도 조건
+ paymentBeforeDelivery: contract.paymentBeforeDelivery,
+ paymentDelivery: contract.paymentDelivery,
+ paymentAfterDelivery: contract.paymentAfterDelivery,
+ paymentTerm: contract.paymentTerm,
+ taxType: contract.taxType,
+ liquidatedDamages: contract.liquidatedDamages,
+ liquidatedDamagesPercent: contract.liquidatedDamagesPercent,
+ deliveryType: contract.deliveryType,
+ deliveryTerm: contract.deliveryTerm,
+ shippingLocation: contract.shippingLocation,
+ dischargeLocation: contract.dischargeLocation,
+ contractDeliveryDate: contract.contractDeliveryDate,
+
+ // 추가 조건
+ contractEstablishmentConditions: contract.contractEstablishmentConditions,
+ interlockingSystem: contract.interlockingSystem,
+ mandatoryDocuments: contract.mandatoryDocuments,
+ contractTerminationConditions: contract.contractTerminationConditions
+ }
+ }
+ } catch (error) {
+ console.error('Error getting basic info:', error)
+ return { success: false, error: '기본 정보 조회에 실패했습니다.' }
+ }
+}
+
+
+export async function getCommunicationChannel(contractId: number) {
+ try {
+ const [contract] = await db
+ .select({
+ communicationChannels: generalContracts.communicationChannels
+ })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ return null
+ }
+
+ return contract.communicationChannels as any
+ } catch (error) {
+ console.error('Error getting communication channel:', error)
+ throw new Error('Failed to get communication channel')
+ }
+}
+
+export async function updateCommunicationChannel(contractId: number, communicationData: Record<string, unknown>, userId: number) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ communicationChannels: communicationData,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error updating communication channel:', error)
+ throw new Error('Failed to update communication channel')
+ }
+}
+
+export async function updateLocation(contractId: number, locationData: Record<string, unknown>, userId: number) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ locations: locationData,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error updating location:', error)
+ throw new Error('Failed to update location')
+ }
+}
+
+export async function getLocation(contractId: number) {
+ try {
+ const [contract] = await db
+ .select({
+ locations: generalContracts.locations
+ })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ return null
+ }
+
+ return contract.locations as any
+ } catch (error) {
+ console.error('Error getting location:', error)
+ throw new Error('Failed to get location')
+ }
+}
+
+export async function updateContract(id: number, data: Record<string, unknown>) {
+ try {
+ // 숫자 필드에서 빈 문자열을 null로 변환
+ const cleanedData = { ...data }
+ const numericFields = [
+ 'vendorId',
+ 'projectId',
+ 'warrantyPeriodValue',
+ 'warrantyPeriodMax',
+ 'contractAmount',
+ 'totalAmount',
+ 'availableBudget',
+ 'liquidatedDamages',
+ 'liquidatedDamagesPercent',
+ 'lastUpdatedById'
+ ]
+
+ // 모든 필드에서 빈 문자열, undefined, 빈 객체 등을 정리
+ Object.keys(cleanedData).forEach(key => {
+ const value = cleanedData[key]
+
+ // 빈 문자열을 null로 변환
+ if (value === '') {
+ cleanedData[key] = null
+ }
+
+ // 빈 객체를 null로 변환
+ if (value && typeof value === 'object' && Object.keys(value).length === 0) {
+ cleanedData[key] = null
+ }
+ })
+
+ // 숫자 필드들 추가 정리 (vendorId는 NOT NULL이므로 null로 설정하지 않음)
+ numericFields.forEach(field => {
+ if (field === 'vendorId') {
+ // vendorId는 필수 필드이므로 null로 설정하지 않음
+ if (cleanedData[field] === '' || cleanedData[field] === undefined || cleanedData[field] === 0) {
+ // 유효하지 않은 값이면 에러 발생
+ throw new Error('Vendor ID is required and cannot be null')
+ }
+ } else {
+ // 다른 숫자 필드들은 빈 값이면 null로 설정
+ if (cleanedData[field] === '' || cleanedData[field] === undefined || cleanedData[field] === 0) {
+ cleanedData[field] = null
+ }
+ }
+ })
+
+ const [updatedContract] = await db
+ .update(generalContracts)
+ .set({
+ ...cleanedData,
+ lastUpdatedAt: new Date(),
+ revision: (cleanedData.revision as number) ? (cleanedData.revision as number) + 1 : 0,
+ })
+ .where(eq(generalContracts.id, id))
+ .returning()
+
+ // Update contract items if provided
+ if (data.contractItems && Array.isArray(data.contractItems)) {
+ // Delete existing items
+ await db
+ .delete(generalContractItems)
+ .where(eq(generalContractItems.contractId, id))
+
+ // Insert new items
+ if (data.contractItems.length > 0) {
+ await db
+ .insert(generalContractItems)
+ .values(
+ data.contractItems.map((item: any) => ({
+ project: item.project,
+ itemCode: item.itemCode,
+ itemInfo: item.itemInfo,
+ specification: item.specification,
+ quantity: item.quantity,
+ quantityUnit: item.quantityUnit,
+ contractDeliveryDate: item.contractDeliveryDate,
+ contractUnitPrice: item.contractUnitPrice,
+ contractAmount: item.contractAmount,
+ contractCurrency: item.contractCurrency,
+ contractId: id,
+ }))
+ )
+ }
+ }
+
+ // Update attachments if provided
+ if (data.attachments && Array.isArray(data.attachments)) {
+ // Delete existing attachments
+ await db
+ .delete(generalContractAttachments)
+ .where(eq(generalContractAttachments.contractId, id))
+
+ // Insert new attachments
+ if (data.attachments.length > 0) {
+ await db
+ .insert(generalContractAttachments)
+ .values(
+ data.attachments.map((attachment: any) => ({
+ ...attachment,
+ contractId: id,
+ }))
+ )
+ }
+ }
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${id}`)
+ return updatedContract
+ } catch (error) {
+ console.error('Error updating contract:', error)
+ throw new Error('Failed to update contract')
+ }
+}
+
+export async function deleteContract(id: number) {
+ try {
+ // 현재 계약 정보 조회
+ await db
+ .select({ revision: generalContracts.revision })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, id))
+ .limit(1)
+
+ // 계약폐기: status를 'Contract Delete'로 변경
+ const [updatedContract] = await db
+ .update(generalContracts)
+ .set({
+ status: 'Contract Delete',
+ lastUpdatedAt: new Date(),
+ // revision: (currentContract[0]?.revision || 0) + 1 // 계약 파기 시 리비전 증가? 확인 필요
+ })
+ .where(eq(generalContracts.id, id))
+ .returning()
+
+ revalidatePath('/general-contracts')
+ return { success: true, contract: updatedContract }
+ } catch (error) {
+ console.error('Error deleting contract:', error)
+ throw new Error('Failed to delete contract')
+ }
+}
+
+// 상태별 개수 집계
+export async function getGeneralContractStatusCounts() {
+ try {
+ const counts = await db
+ .select({
+ status: generalContracts.status,
+ count: count(),
+ })
+ .from(generalContracts)
+ .groupBy(generalContracts.status)
+
+ return counts.reduce((acc, { status, count }) => {
+ acc[status] = count
+ return acc
+ }, {} as Record<string, number>)
+ } catch (error) {
+ console.error('Failed to get contract status counts:', error)
+ return {}
+ }
+}
+
+// 계약구분별 개수 집계
+export async function getGeneralContractCategoryCounts() {
+ try {
+ const counts = await db
+ .select({
+ category: generalContracts.category,
+ count: count(),
+ })
+ .from(generalContracts)
+ .groupBy(generalContracts.category)
+
+ return counts.reduce((acc, { category, count }) => {
+ acc[category] = count
+ return acc
+ }, {} as Record<string, number>)
+ } catch (error) {
+ console.error('Failed to get contract category counts:', error)
+ return {}
+ }
+}
+
+export async function getVendors() {
+ try {
+ const vendorList = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(vendors)
+ .orderBy(asc(vendors.vendorName))
+
+ return vendorList
+ } catch (error) {
+ console.error('Error fetching vendors:', error)
+ throw new Error('Failed to fetch vendors')
+ }
+}
+
+// 첨부파일 업로드
+export async function uploadContractAttachment(contractId: number, file: File, userId: string, documentName: string = '사양 및 공급범위') {
+ try {
+ // userId를 숫자로 변환
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `general-contracts/${contractId}/attachments`,
+ userId,
+ )
+
+ if (saveResult.success && saveResult.publicPath) {
+ // generalContractAttachments 테이블에 저장
+ const [attachment] = await db.insert(generalContractAttachments).values({
+ contractId,
+ documentName,
+ fileName: saveResult.fileName || file.name,
+ filePath: saveResult.publicPath,
+ uploadedById: userIdNumber,
+ uploadedAt: new Date(),
+ }).returning()
+
+ return {
+ success: true,
+ message: '파일이 성공적으로 업로드되었습니다.',
+ attachment
+ }
+ } else {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+ } catch (error) {
+ console.error('Failed to upload contract attachment:', error)
+ return {
+ success: false,
+ error: '파일 업로드에 실패했습니다.'
+ }
+ }
+}
+
+// 첨부파일 목록 조회
+export async function getContractAttachments(contractId: number) {
+ try {
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(eq(generalContractAttachments.contractId, contractId))
+ .orderBy(desc(generalContractAttachments.uploadedAt))
+
+ return attachments
+ } catch (error) {
+ console.error('Failed to get contract attachments:', error)
+ return []
+ }
+}
+
+// 첨부파일 다운로드
+export async function getContractAttachmentForDownload(attachmentId: number, contractId: number) {
+ try {
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(and(
+ eq(generalContractAttachments.id, attachmentId),
+ eq(generalContractAttachments.contractId, contractId)
+ ))
+ .limit(1)
+
+ if (attachments.length === 0) {
+ return {
+ success: false,
+ error: '첨부파일을 찾을 수 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ attachment: attachments[0]
+ }
+ } catch (error) {
+ console.error('Failed to get contract attachment for download:', error)
+ return {
+ success: false,
+ error: '첨부파일 다운로드 준비에 실패했습니다.'
+ }
+ }
+}
+
+// 첨부파일 삭제
+export async function deleteContractAttachment(attachmentId: number, contractId: number) {
+ try {
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(and(
+ eq(generalContractAttachments.id, attachmentId),
+ eq(generalContractAttachments.contractId, contractId)
+ ))
+ .limit(1)
+
+ if (attachments.length === 0) {
+ return {
+ success: false,
+ error: '첨부파일을 찾을 수 없습니다.'
+ }
+ }
+
+ // 데이터베이스에서 삭제
+ await db
+ .delete(generalContractAttachments)
+ .where(eq(generalContractAttachments.id, attachmentId))
+
+ return {
+ success: true,
+ message: '첨부파일이 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete contract attachment:', error)
+ return {
+ success: false,
+ error: '첨부파일 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// 계약승인요청용 파일 업로드 (DRM 사용)
+export async function uploadContractApprovalFile(contractId: number, file: File, userId: string) {
+ try {
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `general-contracts/${contractId}/approval-documents`,
+ userId,
+ )
+
+ if (saveResult.success && saveResult.publicPath) {
+ return {
+ success: true,
+ message: '파일이 성공적으로 업로드되었습니다.',
+ filePath: saveResult.publicPath,
+ fileName: saveResult.fileName || file.name
+ }
+ } else {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+ } catch (error) {
+ console.error('Failed to upload contract approval file:', error)
+ return {
+ success: false,
+ error: '파일 업로드에 실패했습니다.'
+ }
+ }
+}
+
+
+
+// 계약승인요청 전송
+export async function sendContractApprovalRequest(
+ contractSummary: any,
+ pdfBuffer: Uint8Array,
+ documentType: string,
+ userId: string,
+ generatedBasicContracts?: Array<{ key: string; buffer: number[]; fileName: string }>
+) {
+ try {
+ // contracts 테이블에 새 계약 생성 (generalContracts에서 contracts로 복사)
+ const contractData = await mapContractSummaryToDb(contractSummary)
+
+ const [newContract] = await db.insert(contracts).values({
+ ...contractData,
+ contractNo: contractData.contractNo || `GC-${Date.now()}`, // contractNumber 대신 contractNo 사용
+ }).returning()
+
+ const contractId = newContract.id
+
+ // const items: {
+ // id: number;
+ // createdAt: Date;
+ // updatedAt: Date;
+ // contractId: number;
+ // itemCode: string | null;
+ // quantity: string | null;
+ // contractAmount: string | null;
+ // contractCurrency: string | null;
+ // contractDeliveryDate: string | null;
+ // specification: string | null;
+ // itemInfo: string | null;
+ // quantityUnit: string | null;
+ // totalWeight: string | null;
+ // weightUnit: string | null;
+ // contractUnitPrice: string | null;
+ // }[]
+
+ // contractItems 테이블에 품목 정보 저장 (general-contract-items가 있을 때만)
+ if (contractSummary.items && contractSummary.items.length > 0) {
+ const projectNo = contractSummary.basicInfo?.projectCode || contractSummary.basicInfo?.projectId?.toString() || 'NULL'
+
+ for (const item of contractSummary.items) {
+ let itemId: number
+
+ // 1. items 테이블에서 itemCode로 기존 아이템 검색
+ if (item.itemCode) {
+ // const existingItem = await db
+ // .select({ id: items.id })
+ // .from(items)
+ // .where(and(
+ // eq(items.itemCode, item.itemCode),
+ // eq(items.ProjectNo, projectNo)
+ // ))
+ // .limit(1)
+ const existingItem = await db
+ .select({ id: items.id })
+ .from(items)
+ .where(
+ eq(items.itemCode, item.itemCode)
+ )
+ .limit(1)
+
+ if (existingItem.length > 0) {
+ // 기존 아이템이 있으면 해당 ID 사용
+ itemId = existingItem[0].id
+ } else {
+ // 기존 아이템이 없으면 새로 생성
+ const newItem = await db.insert(items).values({
+ ProjectNo: projectNo,
+ itemCode: item.itemCode,
+ itemName: item.itemInfo || item.description || item.itemCode,
+ packageCode: item.itemCode,
+ description: item.specification || item.description || '',
+ unitOfMeasure: item.quantityUnit || 'EA',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }).returning({ id: items.id })
+
+ itemId = newItem[0].id
+ }
+
+
+ // 2. contractItems에 저장
+ await db.insert(contractItems).values({
+ contractId,
+ itemId: itemId,
+ description: item.itemInfo || item.description || '',
+ quantity: Math.floor(Number(item.quantity) || 1), // 정수로 변환
+ unitPrice: item.contractUnitPrice || item.unitPrice || 0,
+ taxRate: item.taxRate || 0,
+ taxAmount: item.taxAmount || 0,
+ totalLineAmount: item.contractAmount || item.totalLineAmount || 0,
+ remark: item.remark || '',
+ })
+ }else{
+ //아이템코드가 없으니 pass
+ continue
+ }
+ }
+ }
+
+ // PDF 버퍼를 saveBuffer 함수로 저장
+ const fileId = uuidv4()
+ const fileName = `${fileId}.pdf`
+
+ // PDF 버퍼를 Buffer로 변환
+ let bufferData: Buffer
+ if (Buffer.isBuffer(pdfBuffer)) {
+ bufferData = pdfBuffer
+ } else if (pdfBuffer instanceof ArrayBuffer) {
+ bufferData = Buffer.from(pdfBuffer)
+ } else if (pdfBuffer instanceof Uint8Array) {
+ bufferData = Buffer.from(pdfBuffer)
+ } else {
+ bufferData = Buffer.from(pdfBuffer as any)
+ }
+
+ // saveBuffer 함수를 사용해서 파일 저장
+ const saveResult = await saveBuffer({
+ buffer: bufferData,
+ fileName: fileName,
+ directory: "generalContracts",
+ originalName: `contract_${contractId}_${documentType}_${fileId}.pdf`,
+ userId: userId
+ })
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || 'PDF 파일 저장에 실패했습니다.')
+ }
+
+ const finalFileName = saveResult.fileName || fileName
+ const finalFilePath = saveResult.publicPath
+ ? saveResult.publicPath.replace('/api/files/', '')
+ : `/generalContracts/${fileName}`
+
+ // contractEnvelopes 테이블에 서명할 PDF 파일 정보 저장
+ const [newEnvelope] = await db.insert(contractEnvelopes).values({
+ contractId: contractId,
+ envelopeId: `envelope_${contractId}_${Date.now()}`,
+ documentId: `document_${contractId}_${Date.now()}`,
+ envelopeStatus: 'PENDING',
+ fileName: finalFileName,
+ filePath: finalFilePath,
+ }).returning()
+
+ // contractSigners 테이블에 벤더 서명자 정보 저장
+ const vendorEmail = contractSummary.basicInfo?.vendorEmail || 'vendor@example.com'
+ const vendorName = contractSummary.basicInfo?.vendorName || '벤더'
+
+ await db.insert(contractSigners).values({
+ envelopeId: newEnvelope.id,
+ signerType: 'VENDOR',
+ signerEmail: vendorEmail,
+ signerName: vendorName,
+ signerPosition: '대표자',
+ signerStatus: 'PENDING',
+ })
+
+ // generalContractAttachments에 contractId 업데이트 (일반계약의 첨부파일들을 PO 계약과 연결)
+ const generalContractId = contractSummary.basicInfo?.id || contractSummary.id
+ if (generalContractId) {
+ await db.update(generalContractAttachments)
+ .set({ poContractId: contractId })
+ .where(eq(generalContractAttachments.contractId, generalContractId))
+ }
+
+ // 기본계약 처리 (클라이언트에서 생성된 PDF 사용 또는 자동 생성)
+ await processGeneratedBasicContracts(contractSummary, contractId, userId, generatedBasicContracts)
+
+ try {
+ sendEmail({
+ to: contractSummary.basicInfo.vendorEmail,
+ subject: `계약승인요청`,
+ template: "contract-approval-request",
+ context: {
+ contractId: contractId,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/po`,
+ language: "ko",
+ },
+ })
+ // 계약 상태 업데이트
+ await db.update(generalContracts)
+ .set({
+ status: 'Contract Accept Request',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, generalContractId))
+
+ } catch (error) {
+ console.error('계약승인요청 전송 오류:', error)
+
+ }
+
+
+ revalidatePath('/evcp/general-contracts')
+ revalidatePath('/evcp/general-contracts/detail')
+ revalidatePath('/evcp/general-contracts/detail/contract-approval-request-dialog')
+
+ return {
+ success: true,
+ message: '계약승인요청이 성공적으로 전송되었습니다.',
+ pdfPath: saveResult.publicPath
+ }
+
+ } catch (error: any) {
+ console.error('계약승인요청 전송 오류:', error)
+
+ // 중복 계약 번호 오류 처리
+ if (error.message && error.message.includes('duplicate key value violates unique constraint')) {
+ return {
+ success: false,
+ error: '이미 존재하는 계약번호입니다. 다른 계약번호를 사용해주세요.'
+ }
+ }
+
+ // 다른 데이터베이스 오류 처리
+ if (error.code === '23505') { // PostgreSQL unique constraint violation
+ return {
+ success: false,
+ error: '중복된 데이터가 존재합니다. 입력값을 확인해주세요.'
+ }
+ }
+
+ return {
+ success: false,
+ error: `계약승인요청 전송 중 오류가 발생했습니다: ${error.message}`
+ }
+ }
+}
+
+// 클라이언트에서 생성된 기본계약 처리 (RFQ-Last 방식)
+async function processGeneratedBasicContracts(
+ contractSummary: any,
+ contractId: number,
+ userId: string,
+ generatedBasicContracts: Array<{ key: string; buffer: number[]; fileName: string }>
+): Promise<void> {
+ try {
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ console.log(`${generatedBasicContracts.length}개의 클라이언트 생성 기본계약을 처리합니다.`)
+
+ // 기본계약 디렉토리 생성 (RFQ-Last 방식)
+ const nasPath = process.env.NAS_PATH || "/evcp_nas"
+ const isProduction = process.env.NODE_ENV === "production"
+ const baseDir = isProduction ? nasPath : path.join(process.cwd(), "public")
+ const contractsDir = path.join(baseDir, "basicContracts")
+ await fs.mkdir(contractsDir, { recursive: true })
+
+ for (const contractData of generatedBasicContracts) {
+ try {
+ console.log(contractSummary.basicInfo?.vendorId || 'unknown', contractData.buffer.length)
+
+ // PDF 버퍼를 Buffer로 변환 및 파일 저장
+ const pdfBuffer = Buffer.from(contractData.buffer)
+ const fileName = contractData.fileName
+ const filePath = path.join(contractsDir, fileName)
+
+ await fs.writeFile(filePath, pdfBuffer)
+
+ // key에서 템플릿 정보 추출 (vendorId_type_templateName 형식)
+ const keyParts = contractData.key.split('_')
+ const vendorId = parseInt(keyParts[0])
+ const contractType = keyParts[1]
+ const templateName = keyParts.slice(2).join('_')
+
+ // 템플릿 조회
+ const template = await getTemplateByName(templateName)
+
+ console.log("템플릿", templateName, template)
+
+ if (template) {
+ // 웹 접근 경로 설정 (RFQ-Last 방식)
+ let filePublicPath: string
+ if (isProduction) {
+ filePublicPath = `/api/files/basicContracts/${fileName}`
+ } else {
+ filePublicPath = `/basicContracts/${fileName}`
+ }
+
+ // basicContract 테이블에 저장
+ const deadline = new Date()
+ deadline.setDate(deadline.getDate() + 10) // 10일 후 마감
+
+ await db.insert(basicContract).values({
+ templateId: template.id,
+ vendorId: vendorId,
+ requestedBy: userIdNumber,
+ generalContractId: contractSummary.basicInfo?.id || contractSummary.id,
+ fileName: fileName,
+ filePath: filePublicPath,
+ deadline: deadline.toISOString().split('T')[0], // YYYY-MM-DD 형식으로
+ status: 'PENDING'
+ })
+
+ console.log(`클라이언트 생성 기본계약 저장 완료:${contractData.fileName}`)
+ } else {
+ console.error(`템플릿을 찾을 수 없음: ${templateName}`)
+ }
+
+ } catch (error) {
+ console.error(`기본계약 처리 실패 (${contractData.fileName}):`, error)
+ // 개별 계약서 처리 실패는 전체 프로세스를 중단하지 않음
+ }
+ }
+
+ } catch (error) {
+ console.error('클라이언트 생성 기본계약 처리 중 오류:', error)
+ // 기본계약 생성 실패는 계약 승인 요청 전체를 실패시키지 않음
+ }
+}
+
+// 템플릿명으로 템플릿 조회 (RFQ-Last 방식)
+async function getTemplateByName(templateName: string) {
+ const [template] = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ ilike(basicContractTemplates.templateName, `%${templateName}%`),
+ eq(basicContractTemplates.status, "ACTIVE")
+ )
+ )
+ .limit(1)
+
+ return template
+}
+
+async function mapContractSummaryToDb(contractSummary: any) {
+ const basicInfo = contractSummary.basicInfo || {}
+
+ // 계약번호 생성
+ const contractNumber = await generateContractNumber(
+ basicInfo.userId,
+ basicInfo.contractType || basicInfo.type || 'UP'
+ )
+
+ return {
+ // 기본 정보
+ projectId: basicInfo.projectId || null, // 기본값 설정
+ vendorId: basicInfo.vendorId,
+ contractNo: contractNumber,
+ contractName: basicInfo.contractName || '계약승인요청',
+ status: 'PENDING_APPROVAL',
+
+ // 계약 기간
+ startDate: basicInfo.startDate || new Date().toISOString().split('T')[0],
+ endDate: basicInfo.endDate || new Date().toISOString().split('T')[0],
+
+ // 지급/인도 조건
+ paymentTerms: basicInfo.paymentTerm || '',
+ deliveryTerms: basicInfo.deliveryTerm || '',
+ deliveryDate: basicInfo.contractDeliveryDate || basicInfo.deliveryDate || new Date().toISOString().split('T')[0],
+ shippmentPlace: basicInfo.shippingLocation || basicInfo.shippmentPlace || '',
+ deliveryLocation: basicInfo.dischargeLocation || basicInfo.deliveryLocation || '',
+
+ // 금액 정보
+ budgetAmount: Number(basicInfo.totalAmount || basicInfo.contractAmount || 0),
+ budgetCurrency: basicInfo.currency || basicInfo.contractCurrency || 'USD',
+ totalAmountKrw: Number(basicInfo.totalAmount || basicInfo.contractAmount || 0),
+ currency: basicInfo.currency || basicInfo.contractCurrency || 'USD',
+ totalAmount: Number(basicInfo.totalAmount || basicInfo.contractAmount || 0),
+
+ // // SAP ECC 관련 필드들
+ // poVersion: basicInfo.revision || 1,
+ // purchaseDocType: basicInfo.type || 'UP',
+ // purchaseOrg: basicInfo.purchaseOrg || '',
+ // purchaseGroup: basicInfo.purchaseGroup || '',
+ // exchangeRate: Number(basicInfo.exchangeRate || 1),
+
+ // // 계약/보증 관련
+ // contractGuaranteeCode: basicInfo.contractGuaranteeCode || '',
+ // defectGuaranteeCode: basicInfo.defectGuaranteeCode || '',
+ // guaranteePeriodCode: basicInfo.guaranteePeriodCode || '',
+ // advancePaymentYn: basicInfo.advancePaymentYn || 'N',
+
+ // // 전자계약/승인 관련
+ // electronicContractYn: basicInfo.electronicContractYn || 'Y',
+ // electronicApprovalDate: basicInfo.electronicApprovalDate || null,
+ // electronicApprovalTime: basicInfo.electronicApprovalTime || '',
+ // ownerApprovalYn: basicInfo.ownerApprovalYn || 'N',
+
+ // // 기타
+ // plannedInOutFlag: basicInfo.plannedInOutFlag || 'I',
+ // settlementStandard: basicInfo.settlementStandard || 'A',
+ // weightSettlementFlag: basicInfo.weightSettlementFlag || 'N',
+
+ // 연동제 관련
+ priceIndexYn: basicInfo.priceIndexYn || 'N',
+ writtenContractNo: basicInfo.contractNumber || '',
+ contractVersion: basicInfo.revision || 1,
+
+ // // 부분 납품/결제
+ // partialShippingAllowed: basicInfo.partialShippingAllowed || false,
+ // partialPaymentAllowed: basicInfo.partialPaymentAllowed || false,
+
+ // 메모
+ remarks: basicInfo.notes || basicInfo.remarks || '',
+
+ // 버전 관리
+ version: basicInfo.revision || 1,
+
+ // 타임스탬프 (contracts 테이블 스키마에 맞게)
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }
+}
+
+// Field Service Rate 관련 서버 액션들
+export async function getFieldServiceRate(contractId: number) {
+ try {
+ const result = await db
+ .select({ fieldServiceRates: generalContracts.fieldServiceRates })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (result.length === 0) {
+ return null
+ }
+
+ return result[0].fieldServiceRates as Record<string, unknown> || null
+ } catch (error) {
+ console.error('Failed to get field service rate:', error)
+ throw new Error('Field Service Rate 데이터를 불러오는데 실패했습니다.')
+ }
+}
+
+export async function updateFieldServiceRate(
+ contractId: number,
+ fieldServiceRateData: Record<string, unknown>,
+ userId: number
+) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ fieldServiceRates: fieldServiceRateData,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/evcp/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update field service rate:', error)
+ throw new Error('Field Service Rate 업데이트에 실패했습니다.')
+ }
+}
+
+// Offset Details 관련 서버 액션들
+export async function getOffsetDetails(contractId: number) {
+ try {
+ const result = await db
+ .select({ offsetDetails: generalContracts.offsetDetails })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (result.length === 0) {
+ return null
+ }
+
+ return result[0].offsetDetails as Record<string, unknown> || null
+ } catch (error) {
+ console.error('Failed to get offset details:', error)
+ throw new Error('회입/상계내역 데이터를 불러오는데 실패했습니다.')
+ }
+}
+
+export async function updateOffsetDetails(
+ contractId: number,
+ offsetDetailsData: Record<string, unknown>,
+ userId: number
+) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ offsetDetails: offsetDetailsData,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/evcp/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update offset details:', error)
+ throw new Error('회입/상계내역 업데이트에 실패했습니다.')
+ }
+}
+
+// 계약번호 생성 함수
+export async function generateContractNumber(
+ userId?: string,
+ contractType: string
+): Promise<string> {
+ try {
+ // 계약종류 매핑 (2자리) - GENERAL_CONTRACT_TYPES 상수 사용
+ const contractTypeMap: Record<string, string> = {
+ 'UP': 'UP', // 자재단가계약
+ 'LE': 'LE', // 임대차계약
+ 'IL': 'IL', // 개별운송계약
+ 'AL': 'AL', // 연간운송계약
+ 'OS': 'OS', // 외주용역계약
+ 'OW': 'OW', // 도급계약
+ 'IS': 'IS', // 검사계약
+ 'LO': 'LO', // LOI (의향서)
+ 'FA': 'FA', // FA (Frame Agreement)
+ 'SC': 'SC', // 납품합의계약 (Supply Contract)
+ 'OF': 'OF', // 클레임상계계약 (Offset Agreement)
+ 'AW': 'AW', // 사전작업합의 (Advanced Work)
+ 'AD': 'AD', // 사전납품합의 (Advanced Delivery)
+ 'AM': 'AM', // 설계계약
+ 'SC_SELL': 'SC' // 폐기물매각계약 (Scrap) - 납품합의계약과 동일한 코드 사용
+ }
+
+ const typeCode = contractTypeMap[contractType] || 'XX' // 기본값
+ // user 테이블의 user.userCode가 있으면 발주담당자 코드로 사용
+ // userId가 주어졌을 때 user.userCode를 조회, 없으면 '000' 사용
+ let purchaseManagerCode = '000';
+ if (userId) {
+ const user = await db
+ .select({ userCode: users.userCode })
+ .from(users)
+ .where(eq(users.id, parseInt(userId || '0')))
+ .limit(1);
+ if (user[0]?.userCode && user[0].userCode.length >= 3) {
+ purchaseManagerCode = user[0].userCode.substring(0, 3).toUpperCase();
+ }
+ }
+ let managerCode: string
+ if (purchaseManagerCode && purchaseManagerCode.length >= 3) {
+ // 발주담당자 코드가 있으면 3자리 사용
+ managerCode = purchaseManagerCode.substring(0, 3).toUpperCase()
+ } else {
+ // 발주담당자 코드가 없으면 일련번호로 대체 (001부터 시작)
+ const currentYear = new Date().getFullYear()
+ const prefix = `C${typeCode}${currentYear.toString().slice(-2)}`
+
+ // 해당 패턴으로 시작하는 계약번호 중 가장 큰 일련번호 찾기
+ const existingContracts = await db
+ .select({ contractNumber: generalContracts.contractNumber })
+ .from(generalContracts)
+ .where(like(generalContracts.contractNumber, `${prefix}%`))
+ .orderBy(desc(generalContracts.contractNumber))
+ .limit(1)
+
+ let sequenceNumber = 1
+ if (existingContracts.length > 0) {
+ const lastContractNumber = existingContracts[0].contractNumber
+ const lastSequenceStr = lastContractNumber.slice(-3)
+
+ // contractNumber에서 숫자만 추출하여 sequence 찾기
+ const numericParts = lastContractNumber.match(/\d+/g)
+ if (numericParts && numericParts.length > 0) {
+ // 마지막 숫자 부분을 시퀀스로 사용 (일반적으로 마지막 3자리)
+ const potentialSequence = numericParts[numericParts.length - 1]
+ const lastSequence = parseInt(potentialSequence)
+
+ if (!isNaN(lastSequence)) {
+ sequenceNumber = lastSequence + 1
+ }
+ }
+ // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지
+ }
+
+ // 일련번호를 3자리로 포맷팅
+ managerCode = sequenceNumber.toString().padStart(3, '0')
+ }
+
+ // 일련번호 생성 (3자리)
+ const currentYear = new Date().getFullYear()
+ const prefix = `C${managerCode}${typeCode}${currentYear.toString().slice(-2)}`
+
+ // 해당 패턴으로 시작하는 계약번호 중 가장 큰 일련번호 찾기
+ const existingContracts = await db
+ .select({ contractNumber: generalContracts.contractNumber })
+ .from(generalContracts)
+ .where(like(generalContracts.contractNumber, `${prefix}%`))
+ .orderBy(desc(generalContracts.contractNumber))
+ .limit(1)
+
+ let sequenceNumber = 1
+ if (existingContracts.length > 0) {
+ const lastContractNumber = existingContracts[0].contractNumber
+
+ // contractNumber에서 숫자만 추출하여 sequence 찾기
+ const numericParts = lastContractNumber.match(/\d+/g)
+ if (numericParts && numericParts.length > 0) {
+ // 마지막 숫자 부분을 시퀀스로 사용
+ const potentialSequence = numericParts[numericParts.length - 1]
+ const lastSequence = parseInt(potentialSequence)
+
+ if (!isNaN(lastSequence)) {
+ sequenceNumber = lastSequence + 1
+ }
+ }
+ // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지
+ }
+
+ // 최종 계약번호 생성: C + 발주담당자코드(3자리) + 계약종류(2자리) + 연도(2자리) + 일련번호(3자리)
+ const finalSequence = sequenceNumber.toString().padStart(3, '0')
+ const contractNumber = `C${managerCode}${typeCode}${currentYear.toString().slice(-2)}${finalSequence}`
+
+ return contractNumber
+
+ } catch (error) {
+ console.error('계약번호 생성 오류:', error)
+ throw new Error('계약번호 생성에 실패했습니다.')
+ }
+}
+
+// 프로젝트 목록 조회
+export async function getProjects() {
+ try {
+ const projectList = await db
+ .select({
+ id: projects.id,
+ code: projects.code,
+ name: projects.name,
+ type: projects.type,
+ })
+ .from(projects)
+ .orderBy(asc(projects.name))
+
+ return projectList
+ } catch (error) {
+ console.error('Error fetching projects:', error)
+ throw new Error('Failed to fetch projects')
+ }
+}
diff --git a/lib/general-contracts_old/types.ts b/lib/general-contracts_old/types.ts
new file mode 100644
index 00000000..2b6731b6
--- /dev/null
+++ b/lib/general-contracts_old/types.ts
@@ -0,0 +1,125 @@
+// 일반계약 관련 타입 정의
+
+// 1. 계약구분
+export const GENERAL_CONTRACT_CATEGORIES = [
+ 'unit_price', // 단가계약
+ 'general', // 일반계약
+ 'sale' // 매각계약
+] as const;
+
+export type GeneralContractCategory = typeof GENERAL_CONTRACT_CATEGORIES[number];
+
+// 2. 계약종류
+export const GENERAL_CONTRACT_TYPES = [
+ 'UP', // 자재단가계약
+ 'LE', // 임대차계약
+ 'IL', // 개별운송계약
+ 'AL', // 연간운송계약
+ 'OS', // 외주용역계약
+ 'OW', // 도급계약
+ 'IS', // 검사계약
+ 'LO', // LOI (의향서)
+ 'FA', // FA (Frame Agreement)
+ 'SC', // 납품합의계약 (Supply Contract)
+ 'OF', // 클레임상계계약 (Offset Agreement)
+ 'AW', // 사전작업합의 (Advanced Work)
+ 'AD', // 사전납품합의 (Advanced Delivery)
+ 'AM', // 설계계약
+ 'SC_SELL' // 폐기물매각계약 (Scrap) - 납품합의계약과 코드 중복으로 별도 명명
+] as const;
+
+export type GeneralContractType = typeof GENERAL_CONTRACT_TYPES[number];
+
+// 3. 계약상태
+export const GENERAL_CONTRACT_STATUSES = [
+ 'Draft', // 임시 저장
+ 'Request to Review', // 조건검토요청
+ 'Confirm to Review', // 조건검토완료
+ 'Contract Accept Request', // 계약승인요청
+ 'Complete the Contract', // 계약체결(승인)
+ 'Reject to Accept Contract', // 계약승인거절
+ 'Contract Delete', // 계약폐기
+ 'PCR Request', // PCR요청
+ 'VO Request', // VO 요청
+ 'PCR Accept', // PCR승인
+ 'PCR Reject' // PCR거절
+] as const;
+
+export type GeneralContractStatus = typeof GENERAL_CONTRACT_STATUSES[number];
+
+// 4. 체결방식
+export const GENERAL_EXECUTION_METHODS = [
+ '전자계약',
+ '오프라인계약'
+] as const;
+
+export type GeneralExecutionMethod = typeof GENERAL_EXECUTION_METHODS[number];
+
+// 6. 계약확정범위
+export const GENERAL_CONTRACT_SCOPES = [
+ '단가',
+ '금액',
+ '물량',
+ '기타'
+] as const;
+
+export type GeneralContractScope = typeof GENERAL_CONTRACT_SCOPES[number];
+
+// 7. 납기종류
+export const GENERAL_DELIVERY_TYPES = [
+ '단일납기',
+ '분할납기',
+ '구간납기'
+] as const;
+
+export type GeneralDeliveryType = typeof GENERAL_DELIVERY_TYPES[number];
+
+// 8. 연동제 적용 여부
+export const GENERAL_LINKAGE_TYPES = [
+ 'Y',
+ 'N'
+] as const;
+
+export type GeneralLinkageType = typeof GENERAL_LINKAGE_TYPES[number];
+
+// 9. 하도급법 점검결과
+export const GENERAL_COMPLIANCE_RESULTS = [
+ '준수',
+ '위반',
+ '위반의심'
+] as const;
+
+export type GeneralComplianceResult = typeof GENERAL_COMPLIANCE_RESULTS[number];
+
+// 타입 가드 함수들
+export const isGeneralContractCategory = (value: string): value is GeneralContractCategory => {
+ return GENERAL_CONTRACT_CATEGORIES.includes(value as GeneralContractCategory);
+};
+
+export const isGeneralContractType = (value: string): value is GeneralContractType => {
+ return GENERAL_CONTRACT_TYPES.includes(value as GeneralContractType);
+};
+
+export const isGeneralContractStatus = (value: string): value is GeneralContractStatus => {
+ return GENERAL_CONTRACT_STATUSES.includes(value as GeneralContractStatus);
+};
+
+export const isGeneralExecutionMethod = (value: string): value is GeneralExecutionMethod => {
+ return GENERAL_EXECUTION_METHODS.includes(value as GeneralExecutionMethod);
+};
+
+export const isGeneralContractScope = (value: string): value is GeneralContractScope => {
+ return GENERAL_CONTRACT_SCOPES.includes(value as GeneralContractScope);
+};
+
+export const isGeneralDeliveryType = (value: string): value is GeneralDeliveryType => {
+ return GENERAL_DELIVERY_TYPES.includes(value as GeneralDeliveryType);
+};
+
+export const isGeneralLinkageType = (value: string): value is GeneralLinkageType => {
+ return GENERAL_LINKAGE_TYPES.includes(value as GeneralLinkageType);
+};
+
+export const isGeneralComplianceResult = (value: string): value is GeneralComplianceResult => {
+ return GENERAL_COMPLIANCE_RESULTS.includes(value as GeneralComplianceResult);
+};
diff --git a/lib/general-contracts_old/validation.ts b/lib/general-contracts_old/validation.ts
new file mode 100644
index 00000000..5aa516e7
--- /dev/null
+++ b/lib/general-contracts_old/validation.ts
@@ -0,0 +1,82 @@
+import { generalContracts } from "@/db/schema/generalContract"
+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<typeof generalContracts.$inferSelect>().withDefault([
+ { id: "registeredAt", desc: true },
+ ]),
+
+ // 기본 필터
+ contractNumber: parseAsString.withDefault(""),
+ name: parseAsString.withDefault(""),
+ status: parseAsArrayOf(z.enum(generalContracts.status.enumValues)).withDefault([]),
+ category: parseAsArrayOf(z.enum(generalContracts.category.enumValues)).withDefault([]),
+ type: parseAsArrayOf(z.enum(generalContracts.type.enumValues)).withDefault([]),
+ executionMethod: parseAsArrayOf(z.enum(generalContracts.executionMethod.enumValues)).withDefault([]),
+ contractSourceType: parseAsArrayOf(z.enum(generalContracts.contractSourceType.enumValues)).withDefault([]),
+ vendorId: parseAsInteger.withDefault(0),
+ managerName: parseAsString.withDefault(""),
+
+ // 날짜 필터
+ registeredAtFrom: parseAsString.withDefault(""),
+ registeredAtTo: parseAsString.withDefault(""),
+ signedAtFrom: parseAsString.withDefault(""),
+ signedAtTo: parseAsString.withDefault(""),
+ startDateFrom: parseAsString.withDefault(""),
+ startDateTo: parseAsString.withDefault(""),
+ endDateFrom: parseAsString.withDefault(""),
+ endDateTo: parseAsString.withDefault(""),
+
+ // 금액 필터
+ contractAmountMin: parseAsString.withDefault(""),
+ contractAmountMax: parseAsString.withDefault(""),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+export type GetGeneralContractsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+
+export const createGeneralContractSchema = z.object({
+ contractNumber: z.string().optional(),
+ name: z.string().min(1, "계약명을 입력해주세요"),
+ category: z.string().min(1, "계약구분을 선택해주세요"),
+ type: z.string().min(1, "계약종류를 선택해주세요"),
+ executionMethod: z.string().min(1, "체결방식을 선택해주세요"),
+ vendorId: z.number().min(1, "협력업체를 선택해주세요"),
+ startDate: z.string().min(1, "계약시작일을 선택해주세요"),
+ endDate: z.string().min(1, "계약종료일을 선택해주세요"),
+ validityEndDate: z.string().optional(),
+ contractScope: z.string().optional(),
+ specificationType: z.string().optional(),
+ specificationManualText: z.string().optional(),
+ contractAmount: z.number().optional(),
+ currency: z.string().default("KRW"),
+ notes: z.string().optional(),
+ linkedRfqOrItb: z.string().optional(),
+ linkedPoNumber: z.string().optional(),
+ linkedBidNumber: z.string().optional(),
+ registeredById: z.number().min(1, "등록자 ID가 필요합니다"),
+ lastUpdatedById: z.number().min(1, "수정자 ID가 필요합니다"),
+})
+
+export const updateGeneralContractSchema = createGeneralContractSchema.partial().extend({
+ id: z.number().min(1, "계약 ID가 필요합니다"),
+})
+
+export type CreateGeneralContractInput = z.infer<typeof createGeneralContractSchema>
+export type UpdateGeneralContractInput = z.infer<typeof updateGeneralContractSchema>
diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts
index 2a81ddec..bb497f3b 100644
--- a/lib/vendor-document-list/enhanced-document-service.ts
+++ b/lib/vendor-document-list/enhanced-document-service.ts
@@ -1609,7 +1609,21 @@ export async function getDocumentDetails(documentId: number) {
const docNumber = formData.get(`docNumber_${i}`) as string
const revision = formData.get(`revision_${i}`) as string
- if (!file || !docNumber) continue
+ // 디버깅 로그 추가
+ console.log(`📋 파일 ${i} 처리:`, {
+ fileName: file?.name,
+ fileType: file?.type,
+ fileSize: file?.size,
+ docNumber,
+ revision,
+ hasFile: !!file,
+ hasDocNumber: !!docNumber
+ })
+
+ if (!file || !docNumber) {
+ console.warn(`⚠️ 파일 ${i} 스킵: file=${!!file}, docNumber=${!!docNumber}`)
+ continue
+ }
if (!fileGroups.has(docNumber)) {
fileGroups.set(docNumber, [])
@@ -1746,6 +1760,14 @@ export async function getDocumentDetails(documentId: number) {
}
// 파일 저장
+ console.log(`💾 파일 저장 시작:`, {
+ fileName: fileInfo.file.name,
+ fileType: fileInfo.file.type,
+ fileSize: fileInfo.file.size,
+ docNumber,
+ revision: fileInfo.revision
+ })
+
const saveResult = await saveFile({
file: fileInfo.file,
directory: `documents/${existingDoc.id}/revisions/${revisionId}`,
@@ -1754,8 +1776,17 @@ export async function getDocumentDetails(documentId: number) {
})
if (!saveResult.success) {
+ console.error(`❌ 파일 저장 실패:`, {
+ fileName: fileInfo.file.name,
+ error: saveResult.error
+ })
throw new Error(saveResult.error || "파일 저장 실패")
}
+
+ console.log(`✅ 파일 저장 성공:`, {
+ fileName: fileInfo.file.name,
+ publicPath: saveResult.publicPath
+ })
// 첨부파일 정보 저장
const [newAttachment] = await db.insert(documentAttachments).values({
diff --git a/lib/vendor-document-list/service.ts b/lib/vendor-document-list/service.ts
index 76bdac49..502d9352 100644
--- a/lib/vendor-document-list/service.ts
+++ b/lib/vendor-document-list/service.ts
@@ -4,6 +4,7 @@ import { eq, SQL } from "drizzle-orm"
import db from "@/db/db"
import { documents, documentStagesView, issueStages } from "@/db/schema/vendorDocu"
import { contracts } from "@/db/schema"
+import { projects } from "@/db/schema/projects"
import { GetVendorDcoumentsSchema } from "./validations"
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
@@ -323,4 +324,39 @@ export async function getProjectIdsByVendor(vendorId: number): Promise<number[]>
console.error('Error fetching contract IDs by vendor:', error)
return []
}
+}
+
+/**
+ * 프로젝트 ID 배열로 프로젝트 정보를 조회하는 서버 액션
+ * @param projectIds - 프로젝트 ID 배열
+ * @returns 프로젝트 정보 배열 [{ id, code, name }]
+ */
+export async function getProjectsByIds(projectIds: number[]): Promise<Array<{ id: number; code: string; name: string }>> {
+ try {
+ if (projectIds.length === 0) {
+ return []
+ }
+
+ // null 값 제거
+ const validProjectIds = projectIds.filter((id): id is number => id !== null && !isNaN(id))
+
+ if (validProjectIds.length === 0) {
+ return []
+ }
+
+ const projectsData = await db
+ .select({
+ id: projects.id,
+ code: projects.code,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(inArray(projects.id, validProjectIds))
+ .orderBy(projects.code)
+
+ return projectsData
+ } catch (error) {
+ console.error('프로젝트 정보 조회 중 오류:', error)
+ return []
+ }
} \ No newline at end of file
diff --git a/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx
index 3ff2f467..be656a48 100644
--- a/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx
+++ b/lib/vendor-document-list/ship/bulk-b4-upload-dialog.tsx
@@ -163,9 +163,53 @@ export function BulkB4UploadDialog({
setPendingProjectId("")
}
+ // 파일 검증 함수
+ const validateFile = (file: File): { valid: boolean; error?: string } => {
+ const MAX_FILE_SIZE = 1024 * 1024 * 1024 // 1GB
+ const FORBIDDEN_EXTENSIONS = ['exe', 'com', 'dll', 'vbs', 'js', 'asp', 'aspx', 'bat', 'cmd']
+
+ // 파일 크기 검증
+ if (file.size > MAX_FILE_SIZE) {
+ return {
+ valid: false,
+ error: `파일 크기가 1GB를 초과합니다 (${(file.size / (1024 * 1024 * 1024)).toFixed(2)}GB)`
+ }
+ }
+
+ // 파일 확장자 검증
+ const extension = file.name.split('.').pop()?.toLowerCase()
+ if (extension && FORBIDDEN_EXTENSIONS.includes(extension)) {
+ return {
+ valid: false,
+ error: `금지된 파일 형식입니다 (.${extension})`
+ }
+ }
+
+ return { valid: true }
+ }
+
// 파일 선택 시 파싱
const handleFilesChange = (files: File[]) => {
- const parsed = files.map(file => {
+ const validFiles: File[] = []
+ const invalidFiles: string[] = []
+
+ // 파일 검증
+ files.forEach(file => {
+ const validation = validateFile(file)
+ if (validation.valid) {
+ validFiles.push(file)
+ } else {
+ invalidFiles.push(`${file.name}: ${validation.error}`)
+ }
+ })
+
+ // 유효하지 않은 파일이 있으면 토스트 표시
+ if (invalidFiles.length > 0) {
+ invalidFiles.forEach(msg => toast.error(msg))
+ }
+
+ // 유효한 파일만 파싱
+ const parsed = validFiles.map(file => {
const { docNumber, revision } = parseFileName(file.name)
return {
file,
@@ -429,7 +473,10 @@ export function BulkB4UploadDialog({
}
</p>
<p className="text-xs text-muted-foreground mt-1">
- PDF, DOC, DOCX, XLS, XLSX, DWG, DXF
+ PDF, DOC, DOCX, XLS, XLSX, DWG, DXF (max 1GB per file)
+ </p>
+ <p className="text-xs text-red-600 mt-1 font-medium">
+ Forbidden: .exe, .com, .dll, .vbs, .js, .asp, .aspx, .bat, .cmd
</p>
</label>
</div>
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 76d66960..dfbd0600 100644
--- a/lib/vendor-document-list/ship/import-from-dolce-button.tsx
+++ b/lib/vendor-document-list/ship/import-from-dolce-button.tsx
@@ -24,21 +24,28 @@ import { Separator } from "@/components/ui/separator"
import { SimplifiedDocumentsView } from "@/db/schema"
import { ImportStatus } from "../import-service"
import { useSession } from "next-auth/react"
-import { getProjectIdsByVendor } from "../service"
+import { getProjectIdsByVendor, getProjectsByIds } from "../service"
import { useParams } from "next/navigation"
import { useTranslation } from "@/i18n/client"
-// 🔥 API 응답 캐시 (컴포넌트 외부에 선언하여 인스턴스 간 공유)
+// API 응답 캐시 (컴포넌트 외부에 선언하여 인스턴스 간 공유)
const statusCache = new Map<string, { data: ImportStatus; timestamp: number }>()
const CACHE_TTL = 2 * 60 * 1000 // 2분 캐시
interface ImportFromDOLCEButtonProps {
allDocuments: SimplifiedDocumentsView[]
- projectIds?: number[] // 🔥 미리 계산된 projectIds를 props로 받음
+ projectIds?: number[] // 미리 계산된 projectIds를 props로 받음
onImportComplete?: () => void
}
-// 🔥 디바운스 훅
+// 프로젝트 정보 타입
+interface ProjectInfo {
+ id: number
+ code: string
+ name: string
+}
+
+// 디바운스 훅
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = React.useState(value)
@@ -67,6 +74,7 @@ export function ImportFromDOLCEButton({
const [statusLoading, setStatusLoading] = React.useState(false)
const [vendorProjectIds, setVendorProjectIds] = React.useState<number[]>([])
const [loadingVendorProjects, setLoadingVendorProjects] = React.useState(false)
+ const [projectsMap, setProjectsMap] = React.useState<Map<number, ProjectInfo>>(new Map())
const { data: session } = useSession()
const vendorId = session?.user.companyId
@@ -75,15 +83,15 @@ export function ImportFromDOLCEButton({
const lng = (params?.lng as string) || "ko"
const { t } = useTranslation(lng, "engineering")
- // 🔥 allDocuments에서 projectIds 추출 (props로 전달받은 경우 사용)
+ // allDocuments에서 projectIds 추출 (props로 전달받은 경우 사용)
const documentsProjectIds = React.useMemo(() => {
if (propProjectIds) return propProjectIds // props로 받은 경우 그대로 사용
- const uniqueIds = [...new Set(allDocuments.map(doc => doc.projectId).filter(Boolean))]
+ const uniqueIds = [...new Set(allDocuments.map(doc => doc.projectId).filter((id): id is number => id !== null))]
return uniqueIds.sort()
}, [allDocuments, propProjectIds])
- // 🔥 최종 projectIds (변경 빈도 최소화)
+ // 최종 projectIds (변경 빈도 최소화)
const projectIds = React.useMemo(() => {
if (documentsProjectIds.length > 0) {
return documentsProjectIds
@@ -91,28 +99,10 @@ export function ImportFromDOLCEButton({
return vendorProjectIds
}, [documentsProjectIds, vendorProjectIds])
- // 🔥 projectIds 디바운싱 (API 호출 과다 방지)
+ // projectIds 디바운싱 (API 호출 과다 방지)
const debouncedProjectIds = useDebounce(projectIds, 300)
- // 🔥 주요 projectId 메모이제이션
- const primaryProjectId = React.useMemo(() => {
- if (projectIds.length === 1) return projectIds[0]
-
- if (allDocuments.length > 0) {
- const counts = allDocuments.reduce((acc, doc) => {
- const id = doc.projectId || 0
- acc[id] = (acc[id] || 0) + 1
- return acc
- }, {} as Record<number, number>)
-
- return Number(Object.entries(counts)
- .sort(([,a], [,b]) => b - a)[0]?.[0] || projectIds[0] || 0)
- }
-
- return projectIds[0] || 0
- }, [projectIds, allDocuments])
-
- // 🔥 캐시된 API 호출 함수
+ // 캐시된 API 호출 함수
const fetchImportStatusCached = React.useCallback(async (projectId: number): Promise<ImportStatus | null> => {
const cacheKey = `import-status-${projectId}`
const cached = statusCache.get(cacheKey)
@@ -148,7 +138,7 @@ export function ImportFromDOLCEButton({
}
}, [])
- // 🔥 모든 projectId에 대한 상태 조회 (최적화된 버전)
+ // 모든 projectId에 대한 상태 조회 (최적화된 버전)
const fetchAllImportStatus = React.useCallback(async () => {
if (debouncedProjectIds.length === 0) return
@@ -156,9 +146,9 @@ export function ImportFromDOLCEButton({
const statusMap = new Map<number, ImportStatus>()
try {
- // 🔥 병렬 처리하되 동시 연결 수 제한 (3개씩)
+ // 병렬 처리하되 동시 연결 수 제한 (3개씩)
const batchSize = 3
- const batches = []
+ const batches: number[][] = []
for (let i = 0; i < debouncedProjectIds.length; i += batchSize) {
batches.push(debouncedProjectIds.slice(i, i + batchSize))
@@ -194,7 +184,7 @@ export function ImportFromDOLCEButton({
}
}, [debouncedProjectIds, fetchImportStatusCached, t])
- // 🔥 vendorId로 projects 가져오기 (최적화)
+ // vendorId로 projects 가져오기 (최적화)
React.useEffect(() => {
let isCancelled = false;
@@ -206,7 +196,7 @@ export function ImportFromDOLCEButton({
.then((projectIds) => {
if (!isCancelled) setVendorProjectIds(projectIds);
})
- .catch((error) => {
+ .catch(() => {
if (!isCancelled) toast.error(t('dolceImport.messages.projectFetchError'));
})
.finally(() => {
@@ -215,15 +205,36 @@ export function ImportFromDOLCEButton({
return () => { isCancelled = true; };
}, [allDocuments, vendorId, t]);
+
+ // projectIds로 프로젝트 정보 가져오기 (서버 액션 사용)
+ React.useEffect(() => {
+ if (projectIds.length === 0) return;
+
+ const fetchProjectsInfo = async () => {
+ try {
+ const projectsData = await getProjectsByIds(projectIds);
+
+ const newProjectsMap = new Map<number, ProjectInfo>();
+ projectsData.forEach((project) => {
+ newProjectsMap.set(project.id, project);
+ });
+ setProjectsMap(newProjectsMap);
+ } catch (error) {
+ console.error('프로젝트 정보 조회 실패:', error);
+ }
+ };
+
+ fetchProjectsInfo();
+ }, [projectIds]);
- // 🔥 컴포넌트 마운트 시 상태 조회 (디바운싱 적용)
+ // 컴포넌트 마운트 시 상태 조회 (디바운싱 적용)
React.useEffect(() => {
if (debouncedProjectIds.length > 0) {
fetchAllImportStatus()
}
}, [debouncedProjectIds, fetchAllImportStatus])
- // 🔥 전체 통계 메모이제이션 - 리비전과 첨부파일 추가
+ // 전체 통계 메모이제이션 - 리비전과 첨부파일 추가
const totalStats = React.useMemo(() => {
const statuses = Array.from(importStatusMap.values())
return statuses.reduce((acc, status) => ({
@@ -251,12 +262,7 @@ export function ImportFromDOLCEButton({
})
}, [importStatusMap])
- // 🔥 주요 상태 메모이제이션
- const primaryImportStatus = React.useMemo(() => {
- return importStatusMap.get(primaryProjectId)
- }, [importStatusMap, primaryProjectId])
-
- // 🔥 가져오기 실행 함수 최적화
+ // 가져오기 실행 함수 최적화
const handleImport = React.useCallback(async () => {
if (projectIds.length === 0) return
@@ -268,8 +274,14 @@ export function ImportFromDOLCEButton({
setImportProgress(prev => Math.min(prev + 10, 85))
}, 500)
- // 🔥 순차 처리로 서버 부하 방지
- const results = []
+ // 순차 처리로 서버 부하 방지
+ const results: Array<{
+ success: boolean
+ newCount?: number
+ updatedCount?: number
+ skippedCount?: number
+ error?: string
+ }> = []
for (const projectId of projectIds) {
try {
const response = await fetch('/api/sync/import', {
@@ -304,14 +316,14 @@ export function ImportFromDOLCEButton({
// 결과 집계
const totalResult = results.reduce((acc, result) => ({
- newCount: acc.newCount + (result.newCount || 0),
- updatedCount: acc.updatedCount + (result.updatedCount || 0),
- skippedCount: acc.skippedCount + (result.skippedCount || 0),
+ newCount: (acc.newCount || 0) + (result.newCount || 0),
+ updatedCount: (acc.updatedCount || 0) + (result.updatedCount || 0),
+ skippedCount: (acc.skippedCount || 0) + (result.skippedCount || 0),
success: acc.success && result.success
}), {
- newCount: 0,
- updatedCount: 0,
- skippedCount: 0,
+ newCount: 0 as number,
+ updatedCount: 0 as number,
+ skippedCount: 0 as number,
success: true
})
@@ -341,7 +353,7 @@ export function ImportFromDOLCEButton({
)
}
- // 🔥 캐시 무효화
+ // 캐시 무효화
statusCache.clear()
fetchAllImportStatus()
onImportComplete?.()
@@ -357,14 +369,14 @@ export function ImportFromDOLCEButton({
}
}, [projectIds, fetchAllImportStatus, onImportComplete, t])
- // 🔥 전체 변경 사항 계산
+ // 전체 변경 사항 계산
const totalChanges = React.useMemo(() => {
return totalStats.newDocuments + totalStats.updatedDocuments +
totalStats.newRevisions + totalStats.updatedRevisions +
totalStats.newAttachments + totalStats.updatedAttachments
}, [totalStats])
- // 🔥 상태 뱃지 메모이제이션 - 리비전과 첨부파일 포함
+ // 상태 뱃지 메모이제이션 - 리비전과 첨부파일 포함
const statusBadge = React.useMemo(() => {
if (loadingVendorProjects) {
return <Badge variant="secondary">{t('dolceImport.status.loadingProjectInfo')}</Badge>
@@ -399,16 +411,16 @@ export function ImportFromDOLCEButton({
)
}, [loadingVendorProjects, statusLoading, importStatusMap.size, totalStats.importEnabled, totalChanges, projectIds.length, t])
- // 🔥 가져오기 가능 여부 - 리비전과 첨부파일도 체크
+ // 가져오기 가능 여부 - 리비전과 첨부파일도 체크
const canImport = totalStats.importEnabled && totalChanges > 0
- // 🔥 새로고침 핸들러 최적화
+ // 새로고침 핸들러 최적화
const handleRefresh = React.useCallback(() => {
statusCache.clear() // 캐시 무효화
fetchAllImportStatus()
}, [fetchAllImportStatus])
- // 🔥 자동 동기화 실행 (기존 useEffect들 다음에 추가)
+ // 자동 동기화 실행 (기존 useEffect들 다음에 추가)
React.useEffect(() => {
// 조건: 가져오기 가능하고, 동기화할 항목이 있고, 현재 진행중이 아닐 때
if (canImport && totalChanges > 0 && !isImporting && !isDialogOpen) {
@@ -496,7 +508,7 @@ export function ImportFromDOLCEButton({
<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">
- {t('dolceImport.labels.projectIds')}: {projectIds.join(', ')}
+ {t('dolceImport.labels.projectIds')}: {projectIds.map(id => projectsMap.get(id)?.code || id).join(', ')}
</div>
</div>
)}
@@ -584,9 +596,11 @@ export function ImportFromDOLCEButton({
<div className="mt-2 space-y-2 pl-2 border-l-2 border-muted">
{projectIds.map(projectId => {
const status = importStatusMap.get(projectId)
+ const projectInfo = projectsMap.get(projectId)
+ const projectLabel = projectInfo?.code || projectId
return (
<div key={projectId} className="text-xs">
- <div className="font-medium">{t('dolceImport.labels.projectLabel', { projectId })}</div>
+ <div className="font-medium">{t('dolceImport.labels.projectLabel', { projectId: projectLabel })}</div>
{status ? (
<div className="text-muted-foreground space-y-1">
<div>