summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-01 09:12:09 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-01 09:12:09 +0000
commit18954df6565108a469fb1608ea3715dd9bb1b02d (patch)
tree2675d254c547861a903a32459d89283a324e0e0d /app
parentf91cd16a872d9cda04aeb5c4e31538e3e2bd1895 (diff)
(대표님) 구매 기본계약, gtc 개발
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/basic-contract/vendor-gtc/[id]/page.tsx308
-rw-r--r--app/[lng]/evcp/(evcp)/gtc/[id]/page.tsx160
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>
+ )
+}