diff options
Diffstat (limited to 'app')
5 files changed, 427 insertions, 0 deletions
diff --git a/app/[lng]/evcp/(evcp)/general-contract-template/[id]/not-found.tsx b/app/[lng]/evcp/(evcp)/general-contract-template/[id]/not-found.tsx new file mode 100644 index 00000000..01dfb6a3 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/general-contract-template/[id]/not-found.tsx @@ -0,0 +1,22 @@ +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { ArrowLeft } from "lucide-react" + +export default function NotFound() { + return ( + <div className="container mx-auto py-6"> + <div className="flex flex-col items-center justify-center space-y-4 text-center"> + <h1 className="text-2xl font-bold">템플릿을 찾을 수 없습니다</h1> + <p className="text-muted-foreground"> + 요청하신 일반계약 템플릿이 존재하지 않거나 삭제되었습니다. + </p> + <Link href="/evcp/general-contract-template"> + <Button variant="outline" className="flex items-center gap-2"> + <ArrowLeft className="h-4 w-4" /> + 목록으로 돌아가기 + </Button> + </Link> + </div> + </div> + ) +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/general-contract-template/[id]/page.tsx b/app/[lng]/evcp/(evcp)/general-contract-template/[id]/page.tsx new file mode 100644 index 00000000..897ba46c --- /dev/null +++ b/app/[lng]/evcp/(evcp)/general-contract-template/[id]/page.tsx @@ -0,0 +1,154 @@ +import * as React from "react" +import { notFound } from "next/navigation" +import { ArrowLeft, FileText, Download, Edit } from "lucide-react" +import Link from "next/link" +import { Metadata } from "next" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { formatDateTime } from "@/lib/utils" +import { getContractTemplateById } from "@/lib/general-contract-template/service" +import { TemplateEditorWrapper } from "@/lib/general-contract-template/template/template-editor-wrapper" + +interface GeneralContractTemplateDetailPageProps { + params: Promise<{ id: string; lng: string }> +} + +// 메타데이터 생성 +export async function generateMetadata({ + params +}: GeneralContractTemplateDetailPageProps): Promise<Metadata> { + const resolvedParams = await params + const template = await getContractTemplateById(parseInt(resolvedParams.id)); + + if (!template) { + return { + title: "템플릿을 찾을 수 없음", + description: "요청한 일반계약 템플릿을 찾을 수 없습니다." + }; + } + + return { + title: `${template.contractTemplateName} (v${template.revision}) - 일반계약 템플릿`, + description: `${template.contractTemplateName} 템플릿의 상세 정보 및 편집 페이지입니다.` + }; +} + +export default async function GeneralContractTemplateDetailPage({ + params +}: GeneralContractTemplateDetailPageProps) { + const resolvedParams = await params + const { id, lng } = resolvedParams + + // ID가 숫자인지 확인 + const templateId = parseInt(id) + if (isNaN(templateId)) { + notFound() + } + + // 템플릿 데이터 조회 + const template = await getContractTemplateById(templateId) + if (!template) { + notFound() + } + + // 페이지 새로고침 서버 액션 + const handleRefresh = async () => { + "use server" + // refreshTemplatePage 함수가 필요하면 추가 + }; + + return ( + <div className="container mx-auto py-6 space-y-6"> + {/* Header */} + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-4"> + <Link href={`/${lng}/evcp/general-contract-template`}> + <Button variant="outline" size="sm"> + <ArrowLeft className="mr-2 h-4 w-4" /> + 목록으로 + </Button> + </Link> + <div> + <h1 className="text-2xl font-bold flex items-center"> + <FileText className="mr-2 h-6 w-6 text-blue-500" /> + {template.contractTemplateName} + <Badge variant="outline" className="ml-2"> + v{template.revision} + </Badge> + </h1> + <p className="text-muted-foreground"> + 일반계약 템플릿 상세 정보 및 편집 + </p> + </div> + </div> + + <div className="flex items-center space-x-2"> + {/* DownloadButton 등 필요한 경우 추가 */} + </div> + </div> + + <div className="space-y-4"> + {/* 상단 - 기본 정보만 (최대한 압축) */} + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-sm">기본 정보</CardTitle> + </CardHeader> + <CardContent className="py-2"> + <div className="flex items-center space-x-6"> + <div className="flex items-center space-x-2"> + <label className="text-xs font-medium text-muted-foreground">상태:</label> + <Badge variant={template.status === "ACTIVE" ? "default" : template.status === "DISPOSED" ? "destructive" : "secondary"} className="text-xs h-5"> + {template.status === "ACTIVE" ? "활성" : template.status === "DISPOSED" ? "폐기" : "비활성"} + </Badge> + </div> + + <div className="flex items-center space-x-2"> + <label className="text-xs font-medium text-muted-foreground">버전:</label> + <span className="text-xs font-medium">v{template.revision}</span> + </div> + + <div className="flex items-center space-x-2"> + <label className="text-xs font-medium text-muted-foreground">법무검토:</label> + <Badge variant={template.legalReviewRequired ? "destructive" : "secondary"} className="text-xs h-5"> + {template.legalReviewRequired ? "필요" : "불필요"} + </Badge> + </div> + + <div className="flex items-center space-x-2"> + <label className="text-xs font-medium text-muted-foreground">파일:</label> + <span className="text-xs">{template.fileName || "파일 없음"}</span> + </div> + </div> + </CardContent> + </Card> + + {/* 하단 - 파일 뷰어 (전체 너비, 높이 증가) */} + <Card className="h-[950px]"> + <CardHeader className="pb-2"> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="text-lg flex items-center"> + <Edit className="mr-2 h-5 w-5 text-blue-500" /> + 템플릿 편집기 + </CardTitle> + <CardDescription className="text-sm"> + Word 문서를 편집하고 {'{{변수}}'} 를 설정할 수 있습니다. + </CardDescription> + </div> + </div> + </CardHeader> + <CardContent className="h-[calc(100%-80px)] p-0"> + <TemplateEditorWrapper + templateId={template.id} + filePath={template.filePath} + fileName={template.fileName} + refreshAction={handleRefresh} + /> + </CardContent> + </Card> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/general-contract-template/page.tsx b/app/[lng]/evcp/(evcp)/general-contract-template/page.tsx new file mode 100644 index 00000000..8a652690 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/general-contract-template/page.tsx @@ -0,0 +1,74 @@ +import * as React from "react" +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 { getContractTemplates } from "@/lib/general-contract-template/service" +import { searchParamsTemplatesCache } from "@/lib/general-contract-template/validations" +import { ContractTemplateTable } from "@/lib/general-contract-template/template/general-contract-template" +import { InformationButton } from "@/components/information/information-button" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsTemplatesCache.parse(searchParams) + + const validFilters = getValidFilters(search.filters) + + const promises = Promise.all([ + getContractTemplates({ + ...search, + filters: validFilters, + }), + ]) + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <div className="flex items-center gap-2"> + <h2 className="text-2xl font-bold tracking-tight"> + 일반계약(Contract) 표준양식 관리 + </h2> + <InformationButton pagePath="evcp/general-contract-template" /> + </div> + <p className="text-muted-foreground mt-2"> + 다양한 계약 유형의 표준양식을 관리합니다. LO, FA, PO, CS, EU 등 계약 종류별 템플릿을 등록하고 편집할 수 있습니다. + </p> + </div> + </div> + + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* 필요시 날짜 범위 선택기 추가 */} + {/* <DateRangePicker + triggerSize="sm" + triggerClassName="ml-auto w-56 sm:w-60" + align="end" + shallow={false} + /> */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={10} + searchableColumnCount={2} + filterableColumnCount={4} + cellWidths={["4rem", "6rem", "6rem", "8rem", "20rem", "6rem", "6rem", "8rem", "8rem", "6rem", "6rem", "6rem"]} + shrinkZero + /> + } + > + <ContractTemplateTable promises={promises} /> + </React.Suspense> + </Shell> + ) +} diff --git a/app/api/upload/generalContract/chunk/route.ts b/app/api/upload/generalContract/chunk/route.ts new file mode 100644 index 00000000..c592f771 --- /dev/null +++ b/app/api/upload/generalContract/chunk/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { mkdir, writeFile, appendFile, readFile, rm } from 'fs/promises'; +import path from 'path'; +import { generateHashedFileName, saveBuffer, saveDRMFile } from '@/lib/file-stroage'; +import { decryptWithServerAction } from '@/components/drm/drmUtils'; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + + const chunk = formData.get('chunk') as File; + const filename = formData.get('filename') as string; + const chunkIndex = parseInt(formData.get('chunkIndex') as string); + const totalChunks = parseInt(formData.get('totalChunks') as string); + const fileId = formData.get('fileId') as string; + + if (!chunk || !filename || isNaN(chunkIndex) || isNaN(totalChunks) || !fileId) { + return NextResponse.json({ + success: false, + error: '필수 매개변수가 누락되었습니다' + }, { status: 400 }); + } + + // 임시 디렉토리 생성 + const tempDir = path.join(process.cwd(), 'temp', fileId); + await mkdir(tempDir, { recursive: true }); + + // 청크 파일 저장 + const chunkPath = path.join(tempDir, `chunk-${chunkIndex}`); + const buffer = Buffer.from(await chunk.arrayBuffer()); + await writeFile(chunkPath, buffer); + + console.log(`📦 청크 저장 완료: ${chunkIndex + 1}/${totalChunks} (${buffer.length} bytes)`); + + // 마지막 청크인 경우 모든 청크를 합쳐 최종 파일 생성 + if (chunkIndex === totalChunks - 1) { + console.log(`🔄 파일 병합 시작: ${filename}`); + + try { + // 모든 청크를 순서대로 읽어서 병합 + const chunks: Buffer[] = []; + let totalSize = 0; + + for (let i = 0; i < totalChunks; i++) { + const chunkData = await readFile(path.join(tempDir, `chunk-${i}`)); + chunks.push(chunkData); + totalSize += chunkData.length; + } + + // 모든 청크를 하나의 Buffer로 병합 + const mergedBuffer = Buffer.concat(chunks, totalSize); + console.log(`📄 병합 완료: ${filename} (총 ${totalSize} bytes)`); + + // 환경에 따른 저장 방식 선택 + const isProduction = process.env.NODE_ENV === 'production'; + let saveResult; + + if (isProduction) { + // Production: DRM 파일 처리 + console.log(`🔐 Production 환경 - DRM 파일 처리: ${filename}`); + + const mergedFile = new File([mergedBuffer], filename, { + type: chunk.type || 'application/octet-stream', + lastModified: Date.now(), + }); + + saveResult = await saveDRMFile( + mergedFile, + decryptWithServerAction, // 복호화 함수 + 'general-contract-templates', // 저장 디렉토리 + // userId // 선택사항 + ); + } else { + // Development: 일반 파일 저장 + console.log(`🛠️ Development 환경 - 일반 파일 저장: ${filename}`); + + saveResult = await saveBuffer({ + buffer: mergedBuffer, + fileName: filename, + directory: 'general-contract-templates', + originalName: filename + }); + } + + // 임시 파일 정리 (비동기로 처리) + rm(tempDir, { recursive: true, force: true }) + .then(() => console.log(`🗑️ 임시 파일 정리 완료: ${fileId}`)) + .catch((e: unknown) => console.error('청크 정리 오류:', e)); + + if (saveResult.success) { + const envPrefix = isProduction ? '🔐' : '🛠️'; + console.log(`${envPrefix} 최종 파일 저장 완료: ${saveResult.fileName}`); + + return NextResponse.json({ + success: true, + fileName: filename, + filePath: saveResult.publicPath, + hashedFileName: saveResult.fileName, + fileSize: totalSize, + environment: isProduction ? 'production' : 'development', + processingType: isProduction ? 'DRM' : 'standard' + }); + } else { + const envPrefix = isProduction ? '🔐' : '🛠️'; + console.error(`${envPrefix} 파일 저장 실패:`, saveResult.error); + return NextResponse.json({ + success: false, + error: saveResult.error || '파일 저장에 실패했습니다', + environment: isProduction ? 'production' : 'development' + }, { status: 500 }); + } + + } catch (mergeError) { + console.error('파일 병합 오류:', mergeError); + + // 오류 발생 시 임시 파일 정리 + rm(tempDir, { recursive: true, force: true }) + .catch((e: unknown) => console.error('임시 파일 정리 오류:', e)); + + return NextResponse.json({ + success: false, + error: '파일 병합 중 오류가 발생했습니다' + }, { status: 500 }); + } + } + + return NextResponse.json({ + success: true, + chunkIndex, + message: `청크 ${chunkIndex + 1}/${totalChunks} 업로드 완료` + }); + + } catch (error) { + console.error('청크 업로드 오류:', error); + return NextResponse.json({ + success: false, + error: '서버 오류' + }, { status: 500 }); + } +} + + diff --git a/app/api/upload/generalContract/complete/route.ts b/app/api/upload/generalContract/complete/route.ts new file mode 100644 index 00000000..5f711f07 --- /dev/null +++ b/app/api/upload/generalContract/complete/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createContractTemplate } from '@/lib/general-contract-template/service'; +import { revalidatePath, revalidateTag } from 'next/cache'; +import { createContractTemplateSchema } from '@/lib/general-contract-template/validations'; + +export async function POST(request: NextRequest) { + try { + const json = await request.json(); + const parsed = createContractTemplateSchema.safeParse(json); + + if (!parsed.success) { + return NextResponse.json( + { success: false, error: parsed.error.flatten() }, + { status: 400 } + ); + } + + const { data, error } = await createContractTemplate(parsed.data); + + revalidatePath('/evcp/general-contract-template'); + revalidateTag("general-contract-templates"); + + if (error) { + throw new Error(error); + } + + return NextResponse.json({ success: true, data }); + + } catch (error) { + console.error('템플릿 저장 오류:', error); + return NextResponse.json({ success: false, error: '서버 오류' }, { status: 500 }); + } +} + + |
