summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-27 12:06:26 +0000
commit7548e2ad6948f1c6aa102fcac408bc6c9c0f9796 (patch)
tree8e66703ec821888ad51dcc242a508813a027bf71 /app
parent7eac558470ef179dad626a8e82db5784fe86a556 (diff)
(대표님, 최겸) 기본계약, 입찰, 파일라우트, 계약서명라우트, 인포메이션, 메뉴설정, PQ(메일템플릿 관련)
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/basic-contract/[id]/page.tsx202
-rw-r--r--app/[lng]/evcp/(evcp)/basic-contract/page.tsx2
-rw-r--r--app/[lng]/evcp/(evcp)/bid/[id]/page.tsx52
-rw-r--r--app/[lng]/partners/(partners)/basic-contract/page.tsx6
-rw-r--r--app/[lng]/partners/(partners)/bid/[id]/page.tsx96
-rw-r--r--app/[lng]/partners/(partners)/bid/page.tsx51
-rw-r--r--app/api/files/[...path]/route.ts1
-rw-r--r--app/api/upload/signed-contract/route.ts4
8 files changed, 408 insertions, 6 deletions
diff --git a/app/[lng]/evcp/(evcp)/basic-contract/[id]/page.tsx b/app/[lng]/evcp/(evcp)/basic-contract/[id]/page.tsx
new file mode 100644
index 00000000..c3136496
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/basic-contract/[id]/page.tsx
@@ -0,0 +1,202 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getBasicContractsByTemplateId, getBasicContractTemplateInfo } from "@/lib/basic-contract/service"
+import { searchParamsCacheByTemplateId } from "@/lib/basic-contract/validations"
+import { InformationButton } from "@/components/information/information-button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { FileText, Calendar, AlertTriangle, ArrowLeft } from "lucide-react"
+import { formatDateTime } from "@/lib/utils"
+import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage } from "@/components/ui/breadcrumb"
+import { Button } from "@/components/ui/button"
+import Link from "next/link"
+import { BasicContractsDetailTable } from "@/lib/basic-contract/status-detail/basic-contracts-detail-table"
+
+interface IndexPageProps {
+ params: {
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const id = resolvedParams.id
+ const templateId = parseInt(id, 10)
+
+ if (isNaN(templateId)) {
+ return (
+ <Shell>
+ <div className="text-center py-10">
+ <h1 className="text-2xl font-bold text-gray-900">잘못된 템플릿 ID</h1>
+ <p className="text-gray-500 mt-2">올바른 템플릿을 선택해주세요.</p>
+ </div>
+ </Shell>
+ )
+ }
+
+ const searchParams = await props.searchParams
+ const search = searchParamsCacheByTemplateId.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // 템플릿 정보 조회
+ const templateInfoPromise = getBasicContractTemplateInfo(templateId)
+
+ // 계약서 목록 조회
+ const contractsPromise = getBasicContractsByTemplateId(
+ {
+ ...search,
+ filters: validFilters,
+ },
+ templateId
+ )
+
+ const promises = Promise.all([contractsPromise])
+
+ return (
+ <Shell className="gap-2">
+ {/* 상단 헤더: Breadcrumb (좌) + 뒤로가기 버튼(우) */}
+ <div className="flex items-center justify-between">
+ <Breadcrumb>
+ <BreadcrumbList>
+ <BreadcrumbItem>
+ <BreadcrumbLink href="/evcp">EVCP</BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ <BreadcrumbLink href="/evcp/basic-contract">기본계약서/서약서 관리</BreadcrumbLink>
+ </BreadcrumbItem>
+ <BreadcrumbSeparator />
+ <BreadcrumbItem>
+ <BreadcrumbPage>기본계약서/서약서 관리 상세</BreadcrumbPage>
+ </BreadcrumbItem>
+ </BreadcrumbList>
+ </Breadcrumb>
+
+ <Button asChild variant="outline" size="sm">
+ <Link href="/evcp/basic-contract">
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ 목록으로 돌아가기
+ </Link>
+ </Button>
+ </div>
+
+ {/* 템플릿 정보 섹션 (콤팩트) */}
+ <React.Suspense fallback={<TemplateInfoSkeleton />}>
+ <TemplateInfo templateInfoPromise={templateInfoPromise} />
+ </React.Suspense>
+
+ <Separator />
+
+ {/* 계약서 리스트 제목 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ <h2 className="text-xl font-semibold">계약서 목록</h2>
+ <InformationButton pagePath="partners/basic-contract-detail" />
+ </div>
+ </div>
+
+ {/* 계약서 테이블 */}
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["3rem", "10rem", "15rem", "8rem", "8rem", "10rem", "10rem", "3rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <BasicContractsDetailTable promises={promises} templateId={templateId} />
+ </React.Suspense>
+ </Shell>
+ )
+}
+
+// 템플릿 정보 컴포넌트 (콤팩트 버전)
+async function TemplateInfo({
+ templateInfoPromise,
+}: {
+ templateInfoPromise: Promise<Awaited<ReturnType<typeof getBasicContractTemplateInfo>>>
+}) {
+ const templateInfo = await templateInfoPromise
+
+ if (!templateInfo) {
+ return (
+ <Card>
+ <CardContent className="py-6">
+ <div className="text-center text-gray-500">
+ <AlertTriangle className="h-8 w-8 mx-auto mb-2" />
+ <p>템플릿 정보를 찾을 수 없습니다.</p>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ return (
+ <Card>
+ <CardHeader className="py-4">
+ <div className="flex flex-wrap items-center justify-between gap-2">
+ {/* 좌측: 제목 + 리비전 */}
+ <div className="flex items-center gap-2 min-w-0">
+ <CardTitle className="text-lg font-semibold leading-none truncate">
+ {templateInfo.templateName}
+ </CardTitle>
+ <Badge variant="outline" className="shrink-0">v{templateInfo.revision}</Badge>
+ </div>
+
+ {/* 우측: 상태/법무검토 */}
+ <div className="flex items-center gap-2">
+ {templateInfo.legalReviewRequired && (
+ <Badge variant="secondary" className="shrink-0">법무검토 필요</Badge>
+ )}
+ <Badge
+ variant={templateInfo.status === "ACTIVE" ? "default" : "secondary"}
+ className="shrink-0"
+ >
+ {templateInfo.status === "ACTIVE" ? "활성" : "비활성"}
+ </Badge>
+ </div>
+ </div>
+
+ {/* 메타 정보 한 줄 정리 */}
+ <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+ <span className="inline-flex items-center gap-1">
+ <Calendar className="h-3.5 w-3.5" />
+ 생성일: {formatDateTime(templateInfo.createdAt, "KR")}
+ </span>
+ {templateInfo.fileName && (
+ <>
+ <span className="select-none">•</span>
+ <span className="inline-flex items-center gap-1 min-w-0">
+ <FileText className="h-3.5 w-3.5" />
+ <span className="truncate max-w-[52ch]">템플릿 파일: {templateInfo.fileName}</span>
+ </span>
+ </>
+ )}
+ </div>
+ </CardHeader>
+ </Card>
+ )
+}
+
+// 로딩 스켈레톤 (콤팩트)
+function TemplateInfoSkeleton() {
+ return (
+ <Card>
+ <CardHeader className="py-4">
+ <div className="space-y-2">
+ <div className="h-5 bg-gray-200 rounded w-1/2 animate-pulse" />
+ <div className="h-3 bg-gray-200 rounded w-1/3 animate-pulse" />
+ </div>
+ </CardHeader>
+ </Card>
+ )
+}
diff --git a/app/[lng]/evcp/(evcp)/basic-contract/page.tsx b/app/[lng]/evcp/(evcp)/basic-contract/page.tsx
index 69a65b14..66b3ee31 100644
--- a/app/[lng]/evcp/(evcp)/basic-contract/page.tsx
+++ b/app/[lng]/evcp/(evcp)/basic-contract/page.tsx
@@ -36,7 +36,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 기본계약서 서명 현황
+ 기본계약서/서약서 관리
</h2>
<InformationButton pagePath="evcp/basic-contract" />
</div>
diff --git a/app/[lng]/evcp/(evcp)/bid/[id]/page.tsx b/app/[lng]/evcp/(evcp)/bid/[id]/page.tsx
new file mode 100644
index 00000000..e4051f9b
--- /dev/null
+++ b/app/[lng]/evcp/(evcp)/bid/[id]/page.tsx
@@ -0,0 +1,52 @@
+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}
+ biddingCompanies={detailData.biddingCompanies}
+ prItems={detailData.prItems}
+ />
+ </Suspense>
+ )
+}
diff --git a/app/[lng]/partners/(partners)/basic-contract/page.tsx b/app/[lng]/partners/(partners)/basic-contract/page.tsx
index 68ebade9..e2213c57 100644
--- a/app/[lng]/partners/(partners)/basic-contract/page.tsx
+++ b/app/[lng]/partners/(partners)/basic-contract/page.tsx
@@ -5,7 +5,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
import { Shell } from "@/components/shell"
import { getBasicContractsByVendorId } from "@/lib/basic-contract/service"
-import { searchParamsCache } from "@/lib/basic-contract/validations"
+import { searchParamsVendorCache } from "@/lib/basic-contract/validations"
import { redirect } from "next/navigation"
import { BasicContractsVendorTable } from "@/lib/basic-contract/vendor-table/basic-contract-table"
import { getServerSession } from "next-auth"
@@ -22,7 +22,7 @@ export default async function IndexPage(props: IndexPageProps) {
const vendorId = session?.user.companyId
const searchParams = await props.searchParams
- const search = searchParamsCache.parse(searchParams)
+ const search = searchParamsVendorCache.parse(searchParams)
const validFilters = getValidFilters(search.filters)
@@ -43,7 +43,7 @@ export default async function IndexPage(props: IndexPageProps) {
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold tracking-tight">
- 기본계약서 서명 요청현황
+ 기본계약서 서명 요청
</h2>
<InformationButton pagePath="partners/basic-contract" />
</div>
diff --git a/app/[lng]/partners/(partners)/bid/[id]/page.tsx b/app/[lng]/partners/(partners)/bid/[id]/page.tsx
new file mode 100644
index 00000000..8b1f346d
--- /dev/null
+++ b/app/[lng]/partners/(partners)/bid/[id]/page.tsx
@@ -0,0 +1,96 @@
+import { PartnersBiddingDetail } from '@/lib/bidding/vendor/partners-bidding-detail'
+import { Suspense } from 'react'
+import { Skeleton } from '@/components/ui/skeleton'
+
+import { getServerSession } from 'next-auth'
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+interface PartnersBidDetailPageProps {
+ params: {
+ id: string
+ }
+}
+
+export default async function PartnersBidDetailPage({ params }: PartnersBidDetailPageProps) {
+ const biddingId = parseInt(params.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={<BiddingDetailSkeleton />}>
+ <PartnersBiddingDetail
+ biddingId={biddingId}
+ companyId={companyId}
+ />
+ </Suspense>
+ </div>
+ )
+}
+
+function BiddingDetailSkeleton() {
+ 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="space-y-2">
+ {Array.from({ length: 3 }).map((_, i) => (
+ <Skeleton key={i} className="h-12 w-full" />
+ ))}
+ </div>
+ </div>
+
+ {/* 응찰 폼 스켈레톤 */}
+ <div className="space-y-4">
+ <Skeleton className="h-8 w-32" />
+ <div className="space-y-4">
+ {Array.from({ length: 8 }).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
new file mode 100644
index 00000000..bcb2ad25
--- /dev/null
+++ b/app/[lng]/partners/(partners)/bid/page.tsx
@@ -0,0 +1,51 @@
+import { PartnersBiddingList } from '@/lib/bidding/vendor/partners-bidding-list'
+import { Suspense } from 'react'
+import { Skeleton } from '@/components/ui/skeleton'
+import { getServerSession } from 'next-auth'
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+export default async function PartnersBidPage() {
+ // 세션에서 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 space-y-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-3xl font-bold">입찰 참여</h1>
+ <p className="text-muted-foreground mt-2">
+ 참여 가능한 입찰 목록을 확인하고 응찰하실 수 있습니다.
+ </p>
+ </div>
+ </div>
+
+ <Suspense fallback={<BiddingListSkeleton />}>
+ <PartnersBiddingList companyId={companyId} />
+ </Suspense>
+ </div>
+ )
+}
+
+function BiddingListSkeleton() {
+ return (
+ <div className="space-y-4">
+ <Skeleton className="h-12 w-full" />
+ <div className="space-y-2">
+ {Array.from({ length: 5 }).map((_, i) => (
+ <Skeleton key={i} className="h-16 w-full" />
+ ))}
+ </div>
+ </div>
+ )
+}
diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts
index 2b58ca43..3fb60347 100644
--- a/app/api/files/[...path]/route.ts
+++ b/app/api/files/[...path]/route.ts
@@ -92,6 +92,7 @@ export async function GET(
if (process.env.NODE_ENV === 'production') {
// ✅ 프로덕션: NAS 경로 사용
filePath = path.join(nasPath, requestedPath);
+
} else {
// 개발: public 폴더
filePath = path.join(process.cwd(), 'public', requestedPath);
diff --git a/app/api/upload/signed-contract/route.ts b/app/api/upload/signed-contract/route.ts
index f26e20ba..86109eec 100644
--- a/app/api/upload/signed-contract/route.ts
+++ b/app/api/upload/signed-contract/route.ts
@@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
const uniqueName = uuidv4() + ext;
const publicDir = path.join(process.cwd(), "public", "basicContract");
- const relativePath = `/basicContract/${uniqueName}`;
+ const relativePath = `/basicContract/signed/${uniqueName}`;
const absolutePath = path.join(publicDir, uniqueName);
const buffer = Buffer.from(await file.arrayBuffer());
@@ -35,7 +35,7 @@ export async function POST(request: NextRequest) {
await tx
.update(basicContract)
.set({
- status: "COMPLETED",
+ status: "VENDOR_SIGNED",
fileName: originalName,
filePath: relativePath,
updatedAt: new Date(),