diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/basic-contract/[id]/page.tsx | 202 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/basic-contract/page.tsx | 2 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/bid/[id]/page.tsx | 52 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/basic-contract/page.tsx | 6 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/bid/[id]/page.tsx | 96 | ||||
| -rw-r--r-- | app/[lng]/partners/(partners)/bid/page.tsx | 51 | ||||
| -rw-r--r-- | app/api/files/[...path]/route.ts | 1 | ||||
| -rw-r--r-- | app/api/upload/signed-contract/route.ts | 4 |
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(), |
