diff options
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: < > : " ' | ? * </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: < > : " ' | ? * </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>
+ 본 계약에 해당하는 항목을 아래 안내사항에 따라 'O'인 경우 체크하세요.
+ </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>
+ 본 계약에 해당하는 항목을 아래 안내사항에 따라 'O'인 경우 체크하세요.
+ <br />
+ <strong>※ 'X' 항목에 다음 안내사항이 자동 표기됩니다:</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> |
