diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/evcp/(evcp)/basic-contract/vendor-gtc/[id]/page.tsx | 308 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/gtc/[id]/page.tsx | 160 |
2 files changed, 430 insertions, 38 deletions
diff --git a/app/[lng]/evcp/(evcp)/basic-contract/vendor-gtc/[id]/page.tsx b/app/[lng]/evcp/(evcp)/basic-contract/vendor-gtc/[id]/page.tsx new file mode 100644 index 00000000..830d7146 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/basic-contract/vendor-gtc/[id]/page.tsx @@ -0,0 +1,308 @@ +// page.tsx +import * as React from "react" +import { notFound } from "next/navigation" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { FileText, Calendar, AlertTriangle, ArrowLeft, Building2, FileSignature } from "lucide-react" +import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage } from "@/components/ui/breadcrumb" +import { formatDateTime } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import Link from "next/link" + +import { + getGtcClauses, + getUsersForFilter, + getVendorClausesForDocument +} from "@/lib/gtc-contract/gtc-clauses/service" +import { getGtcDocumentById } from "@/lib/gtc-contract/service" +import { searchParamsCache } from "@/lib/gtc-contract/gtc-clauses/validations" +import { GtcClausesVendorTable } from "@/lib/basic-contract/gtc-vendor/clause-table" +import { UpdateVendorDocumentStatusButton } from "@/lib/basic-contract/vendor-table/update-vendor-document-status-button" + +interface GtcClausesPageProps { + params: Promise<{ id: string }> + searchParams: Promise<SearchParams> +} +export const dynamicParams = true; // 동적 파라미터 허용 (SSG용 경로 강제 생성 방지) +export const revalidate = 0; // (선택) 완전 SSR + +// page.tsx 수정 부분 +export default async function GtcClausesPage(props: GtcClausesPageProps) { + const params = await props.params + const searchParams = await props.searchParams + const documentId = parseInt(params.id) + + if (isNaN(documentId)) { + notFound() + } + + // URL 파라미터에서 벤더 정보 가져오기 + const vendorId = searchParams.vendorId ? parseInt(searchParams.vendorId as string) : undefined + const vendorName = searchParams.vendorName ? decodeURIComponent(searchParams.vendorName as string) : undefined + const contractId = searchParams.contractId ? parseInt(searchParams.contractId as string) : undefined + const templateId = searchParams.templateId ? parseInt(searchParams.templateId as string) : undefined + + // 문서 정보 조회 + const document = await getGtcDocumentById(documentId) + if (!document) { + notFound() + } + + console.log(document, "document") + + const search = searchParamsCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + // 병렬로 데이터 조회 + const promises = Promise.all([ + getGtcClauses({ + ...search, + filters: validFilters, + documentId, + }), + getUsersForFilter(), + getVendorClausesForDocument({ documentId, vendorId }) // vendorId 전달 + ]) + + const [_, __, vendorData] = await promises.then(values => values) + const vendorDocument = vendorData?.vendorDocument || null + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between"> + <Breadcrumb> + <BreadcrumbList> + <BreadcrumbItem> + <BreadcrumbLink href="/evcp">EVCP</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbLink href="/evcp/gtc">GTC 목록 관리</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbPage> + {vendorName ? `${vendorName} GTC 협의` : 'GTC 조항 관리'} + </BreadcrumbPage> + </BreadcrumbItem> + </BreadcrumbList> + </Breadcrumb> + + <Button asChild variant="outline" size="sm"> + <Link href={templateId ? `/evcp/basic-contract/${templateId}` : "/evcp/basic-contract"}> + <ArrowLeft className="h-4 w-4 mr-2" /> + {templateId ? '계약 상세로' : '목록으로'} 돌아가기 + </Link> + </Button> + </div> + + {/* 카드 섹션 - vendorName이 있을 때는 나란히, 없을 때는 DocumentInfo만 */} + <div className={vendorName ? "grid grid-cols-1 lg:grid-cols-2 gap-2" : ""}> + {/* 벤더 정보 섹션 (vendorName이 있을 때만 표시) */} + {vendorName && ( + <Card className="h-fit"> + <CardHeader className="py-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <Building2 className="h-5 w-5 text-muted-foreground" /> + <div> + <CardTitle className="text-base">벤더 협의 정보</CardTitle> + <CardDescription className="text-sm mt-1"> + {vendorName}과(와)의 GTC 조항 협의를 진행합니다 + </CardDescription> + </div> + </div> + {contractId && vendorDocument && ( + <Badge + variant="outline" + className={`flex items-center gap-1 ${ + vendorDocument.reviewStatus === 'complete' + ? 'bg-green-50 text-green-700 border-green-300' + : vendorDocument.reviewStatus === 'reviewing' + ? 'bg-blue-50 text-blue-700 border-blue-300' + : 'bg-gray-50' + }`} + > + <FileSignature className="h-3 w-3" /> + {vendorDocument.reviewStatus === 'draft' ? '초안' : + vendorDocument.reviewStatus === 'reviewing' ? '협의 중' : + vendorDocument.reviewStatus === 'complete' ? '협의 완료' : + vendorDocument.reviewStatus} + </Badge> + )} + </div> + </CardHeader> + + {vendorData && contractId && ( + <CardContent className="py-2"> + <div className="flex justify-between items-center"> + <div className="text-sm text-muted-foreground"> + 총 {vendorData.totalModified || 0}개 조항 협의됨, {vendorData.totalExcluded || 0}개 조항 제외됨 + </div> + + {/* 협의 완료 업데이트 버튼 */} + {vendorDocument && ( + <UpdateVendorDocumentStatusButton + vendorDocumentId={vendorDocument.id} + documentId={documentId} + vendorId={vendorId} + currentStatus={vendorDocument.reviewStatus as "draft" | "complete" | "reviewing"} + /> + )} + </div> + </CardContent> + )} + </Card> + )} + + {/* 템플릿 정보 섹션 */} + <React.Suspense fallback={<TemplateInfoSkeleton />}> + <DocumentInfo document={document} /> + </React.Suspense> + </div> + + {/* 조항 테이블 */} + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={vendorId ? 10 : 8} + searchableColumnCount={2} + filterableColumnCount={vendorId ? 5 : 3} + cellWidths={["10rem", "15rem", "20rem", "30rem", "12rem", "12rem", "12rem", "8rem"]} + shrinkZero + /> + } + > + <GtcClausesVendorTable + promises={promises} + documentId={documentId} + document={document} + vendorId={vendorId} + vendorName={vendorName} + /> + </React.Suspense> + </Shell> + ) +} + +// 메타데이터 생성 함수도 업데이트 +export async function generateMetadata(props: GtcClausesPageProps) { + const params = await props.params + const searchParams = await props.searchParams + const documentId = parseInt(params.id) + const vendorName = searchParams.vendorName ? decodeURIComponent(searchParams.vendorName as string) : undefined + + if (isNaN(documentId)) { + return { + title: "GTC 조항 관리", + } + } + + try { + const document = await getGtcDocumentById(documentId) + + if (!document) { + return { + title: "GTC 조항 관리", + } + } + + const baseTitle = `GTC 조항 관리 - ${document.type === "standard" ? "표준" : "프로젝트"} v${document.revision}` + const title = vendorName ? `${vendorName} ${baseTitle}` : baseTitle + + const description = vendorName + ? `${vendorName}과(와)의 GTC 조항 협의를 관리합니다.` + : document.project + ? `${document.project.name} (${document.project.code}) 프로젝트의 GTC 조항을 관리합니다.` + : "표준 GTC 조항을 관리합니다." + + return { + title, + description, + } + } catch (error) { + return { + title: "GTC 조항 관리", + } + } +} + +// DocumentInfo 컴포넌트는 기존 유지 +async function DocumentInfo({ + document, +}: { + document: Promise<Awaited<ReturnType<typeof getGtcDocumentById>>> +}) { + const documentInfo = await document + + if (!documentInfo) { + 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"> + {documentInfo.title} + </CardTitle> + <Badge variant="outline" className="shrink-0">v{documentInfo.revision}</Badge> + </div> + + <div className="flex items-center gap-2"> + <Badge + variant={documentInfo.isActive ? "default" : "secondary"} + className="shrink-0" + > + {documentInfo.isActive ? "활성" : "비활성"} + </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(documentInfo.createdAt, "KR")} + </span> + {documentInfo.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]">템플릿 파일: {documentInfo.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> + ) +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/gtc/[id]/page.tsx b/app/[lng]/evcp/(evcp)/gtc/[id]/page.tsx index 0f783375..20f0ea51 100644 --- a/app/[lng]/evcp/(evcp)/gtc/[id]/page.tsx +++ b/app/[lng]/evcp/(evcp)/gtc/[id]/page.tsx @@ -6,14 +6,20 @@ import { Skeleton } from "@/components/ui/skeleton" import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" import { Shell } from "@/components/shell" import { InformationButton } from "@/components/information/information-button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { FileText, Calendar, AlertTriangle, ArrowLeft } from "lucide-react" +import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage } from "@/components/ui/breadcrumb" +import { formatDateTime } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import Link from "next/link" -import { - getGtcClauses, +import { + getGtcClauses, getUsersForFilter, } from "@/lib/gtc-contract/gtc-clauses/service" import { getGtcDocumentById } from "@/lib/gtc-contract/service" import { searchParamsCache } from "@/lib/gtc-contract/gtc-clauses/validations" -import { GtcClausesPageHeader } from "@/lib/gtc-contract/gtc-clauses/gtc-clauses-page-header" import { GtcClausesTable } from "@/lib/gtc-contract/gtc-clauses/table/clause-table" interface GtcClausesPageProps { @@ -51,38 +57,38 @@ export default async function GtcClausesPage(props: GtcClausesPageProps) { return ( <Shell className="gap-2"> - {/* 헤더 컴포넌트 */} - <GtcClausesPageHeader document={document} /> - - {/* 문서 정보 카드 */} - <div className="rounded-lg border bg-card p-4"> - <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> - <div> - <div className="font-medium text-muted-foreground">최초등록일</div> - <div>{document.createdAt ? new Date(document.createdAt).toLocaleDateString('ko-KR') : '-'}</div> - </div> - <div> - <div className="font-medium text-muted-foreground">최초등록자</div> - <div>{document.createdByName || '-'}</div> - </div> - <div> - <div className="font-medium text-muted-foreground">최종수정일</div> - <div>{document.updatedAt ? new Date(document.updatedAt).toLocaleDateString('ko-KR') : '-'}</div> - </div> - <div> - <div className="font-medium text-muted-foreground">최종수정자</div> - <div>{document.updatedByName || '-'}</div> - </div> - </div> - - {document.editReason && ( - <div className="mt-3 pt-3 border-t"> - <div className="font-medium text-muted-foreground mb-1">최종 편집사유</div> - <div className="text-sm">{document.editReason}</div> - </div> - )} + + <div className="flex items-center justify-between"> + <Breadcrumb> + <BreadcrumbList> + <BreadcrumbItem> + <BreadcrumbLink href="/evcp">EVCP</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbLink href="/evcp/gtc">GTC 목록 관리</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbPage>GTC 조항 관리</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 />}> + <DocumentInfo document={document} /> + </React.Suspense> + + {/* 조항 테이블 */} <React.Suspense fallback={ @@ -96,7 +102,7 @@ export default async function GtcClausesPage(props: GtcClausesPageProps) { } > <GtcClausesTable - promises={promises} + promises={promises} documentId={documentId} document={document} /> @@ -109,7 +115,7 @@ export default async function GtcClausesPage(props: GtcClausesPageProps) { export async function generateMetadata(props: GtcClausesPageProps) { const params = await props.params const documentId = parseInt(params.id) - + if (isNaN(documentId)) { return { title: "GTC 조항 관리", @@ -118,7 +124,7 @@ export async function generateMetadata(props: GtcClausesPageProps) { try { const document = await getGtcDocumentById(documentId) - + if (!document) { return { title: "GTC 조항 관리", @@ -126,7 +132,7 @@ export async function generateMetadata(props: GtcClausesPageProps) { } const title = `GTC 조항 관리 - ${document.type === "standard" ? "표준" : "프로젝트"} v${document.revision}` - const description = document.project + const description = document.project ? `${document.project.name} (${document.project.code}) 프로젝트의 GTC 조항을 관리합니다.` : "표준 GTC 조항을 관리합니다." @@ -139,4 +145,82 @@ export async function generateMetadata(props: GtcClausesPageProps) { title: "GTC 조항 관리", } } -}
\ No newline at end of file +} + + +async function DocumentInfo({ + document, +}: { + document: Promise<Awaited<ReturnType<typeof getGtcDocumentById>>> +}) { + const documentInfo = await document + + if (!documentInfo) { + 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"> + {documentInfo.title} + </CardTitle> + <Badge variant="outline" className="shrink-0">v{documentInfo.revision}</Badge> + </div> + + <div className="flex items-center gap-2"> + <Badge + variant={documentInfo.isActive ? "default" : "secondary"} + className="shrink-0" + > + {documentInfo.isActive ? "활성" : "비활성"} + </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(documentInfo.createdAt, "KR")} + </span> + {documentInfo.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]">템플릿 파일: {documentInfo.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> + ) +} |
