summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-failure/page.tsx39
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-receive/page.tsx39
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid-selection/page.tsx39
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/bidding-tabs.tsx77
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/companies/page.tsx75
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/detail/page.tsx51
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/info/page.tsx75
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/items/page.tsx76
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/layout.tsx92
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/page.tsx12
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/pre-quote/page.tsx56
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/[id]/schedule/page.tsx73
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bid/page.tsx78
-rw-r--r--app/[lng]/evcp/(evcp)/(procurement)/bidding-notice/page.tsx28
-rw-r--r--app/[lng]/partners/(partners)/bid/[id]/page.tsx2
-rw-r--r--app/[lng]/partners/(partners)/bid/[id]/pre-quote/page.tsx97
-rw-r--r--app/[lng]/partners/(partners)/bid/page.tsx7
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/[contractId]/page.tsx62
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/[contractId]/vendor-contract-review-client.tsx423
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/page.tsx48
-rw-r--r--app/[lng]/partners/(partners)/general-contract-review/vendor-general-contract-review-table.tsx142
21 files changed, 1183 insertions, 408 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>
+ )
+}
+