diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-03 18:46:35 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-03 18:46:35 +0900 |
| commit | 1393acc4b6675fd5eac65c6f1a9e399edfb2d44f (patch) | |
| tree | 6610385198545277ed51c4616d315aa0800c07bc | |
| parent | a9c038e51f1cf508165e9d196ffe332f6ac54d74 (diff) | |
(김준회) SWP: 커버페이지 생성 API 오류 수정
| -rw-r--r-- | app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx | 29 | ||||
| -rw-r--r-- | app/api/projects/[projectId]/cover/route.ts | 78 | ||||
| -rw-r--r-- | app/api/projects/code-to-id/route.ts | 47 | ||||
| -rw-r--r-- | lib/cover/cover-service.ts | 213 | ||||
| -rw-r--r-- | lib/swp/project-utils.ts | 47 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-columns.tsx | 105 | ||||
| -rw-r--r-- | lib/swp/table/swp-table.tsx | 22 | ||||
| -rw-r--r-- | next.config.ts | 2 |
8 files changed, 435 insertions, 108 deletions
diff --git a/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx b/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx index 34ad562f..dc6fbe7c 100644 --- a/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx +++ b/app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx @@ -70,34 +70,6 @@ export default function VendorDocumentPage({ searchParams }: VendorDocumentPageP noKeyboard: true, }); - // 커버페이지 다운로드 핸들러 - const handleCoverDownload = useCallback(async (document: DocumentListItem) => { - try { - toast.info("커버 페이지를 다운로드합니다..."); - - const res = await fetch(`/api/projects/${document.PROJ_NO}/cover`, { - method: "GET" - }); - - if (!res.ok) { - const error = await res.json(); - throw new Error(error.message || "커버 페이지를 가져올 수 없습니다"); - } - - const { fileUrl, fileName } = await res.json(); - - // quickDownload 사용 - const quickDownload = (await import("@/lib/file-download")).quickDownload; - quickDownload(fileUrl, fileName || `${document.PROJ_NO}_cover.docx`); - - toast.success("커버 페이지 다운로드를 시작했습니다."); - - } catch (e) { - toast.error(e instanceof Error ? e.message : "커버 페이지 다운로드에 실패했습니다."); - console.error(e); - } - }, []); - const loadInitialData = useCallback(async () => { try { setIsLoading(true); @@ -303,7 +275,6 @@ export default function VendorDocumentPage({ searchParams }: VendorDocumentPageP projNo={projNo} vendorCode={vendorInfo?.vendorCode || ""} userId={String(vendorInfo?.vendorId || "")} - onCoverDownload={handleCoverDownload} /> </CardContent> </Card> diff --git a/app/api/projects/[projectId]/cover/route.ts b/app/api/projects/[projectId]/cover/route.ts index b88f06ee..802e2ab6 100644 --- a/app/api/projects/[projectId]/cover/route.ts +++ b/app/api/projects/[projectId]/cover/route.ts @@ -1,15 +1,22 @@ // app/api/projects/[projectId]/cover/route.ts import { NextRequest, NextResponse } from "next/server" -import db from "@/db/db" -import { projectCoverTemplates, generatedCoverPages } from "@/db/schema" -import { eq, and, desc } from "drizzle-orm" +import { generateCoverPage } from "@/lib/cover/cover-service" +/** + * 커버페이지 생성 및 다운로드 + * + * GET /api/projects/[projectId]/cover?docNumber=DOC-001 + * + * @param projectId - 프로젝트 ID + * @param docNumber - 문서 번호 (쿼리 파라미터) + */ export async function GET( request: NextRequest, - { params }: { params: { projectId: string } } + { params }: { params: Promise<{ projectId: string }> } ) { try { - const projectId = parseInt(params.projectId) + const { projectId: projectIdParam } = await params + const projectId = parseInt(projectIdParam) if (isNaN(projectId)) { return NextResponse.json( @@ -18,54 +25,51 @@ export async function GET( ) } - // 1. 해당 프로젝트의 활성 템플릿 찾기 - const [activeTemplate] = await db - .select() - .from(projectCoverTemplates) - .where( - and( - eq(projectCoverTemplates.projectId, projectId), - eq(projectCoverTemplates.isActive, true) - ) - ) - .limit(1) + // docNumber 쿼리 파라미터 가져오기 + const { searchParams } = new URL(request.url) + const docNumber = searchParams.get("docNumber") - if (!activeTemplate) { + if (!docNumber) { return NextResponse.json( - { success: false, message: "활성 템플릿을 찾을 수 없습니다" }, - { status: 404 } + { success: false, message: "문서 번호(docNumber)가 필요합니다" }, + { status: 400 } ) } - // 2. 해당 템플릿의 최신 생성된 커버 페이지 찾기 - const [latestCover] = await db - .select() - .from(generatedCoverPages) - .where(eq(generatedCoverPages.templateId, activeTemplate.id)) - .orderBy(desc(generatedCoverPages.generatedAt)) - .limit(1) + console.log(`📄 커버페이지 요청 - ProjectID: ${projectId}, DocNumber: ${docNumber}`) - if (!latestCover) { + // 커버페이지 생성 + const result = await generateCoverPage(projectId, docNumber) + + if (!result.success || !result.buffer) { return NextResponse.json( - { success: false, message: "생성된 커버 페이지를 찾을 수 없습니다" }, - { status: 404 } + { + success: false, + message: result.error || "커버페이지 생성 실패" + }, + { status: 500 } ) } - // 3. 파일 경로와 정보 반환 - return NextResponse.json({ - success: true, - fileUrl: latestCover.filePath, - fileName: latestCover.fileName, - generatedAt: latestCover.generatedAt, + // DOCX 파일로 응답 + // Buffer를 Uint8Array로 변환 + const uint8Array = new Uint8Array(result.buffer) + + return new NextResponse(uint8Array, { + status: 200, + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "Content-Disposition": `attachment; filename="${encodeURIComponent(result.fileName || "cover.docx")}"`, + "Content-Length": result.buffer.length.toString(), + }, }) } catch (error) { - console.error("❌ 커버 페이지 조회 오류:", error) + console.error("❌ 커버 페이지 생성 오류:", error) return NextResponse.json( { success: false, - message: error instanceof Error ? error.message : "조회 중 오류 발생" + message: error instanceof Error ? error.message : "생성 중 오류 발생" }, { status: 500 } ) diff --git a/app/api/projects/code-to-id/route.ts b/app/api/projects/code-to-id/route.ts new file mode 100644 index 00000000..f2fdb3c5 --- /dev/null +++ b/app/api/projects/code-to-id/route.ts @@ -0,0 +1,47 @@ +// app/api/projects/code-to-id/route.ts +import { NextRequest, NextResponse } from "next/server" +import { getProjectIdByCode } from "@/lib/swp/project-utils" + +/** + * 프로젝트 코드로 프로젝트 ID 조회 + * + * GET /api/projects/code-to-id?code=PROJ_CODE + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const code = searchParams.get("code") + + if (!code) { + return NextResponse.json( + { success: false, message: "프로젝트 코드(code)가 필요합니다" }, + { status: 400 } + ) + } + + const projectId = await getProjectIdByCode(code) + + if (!projectId) { + return NextResponse.json( + { success: false, message: "프로젝트를 찾을 수 없습니다" }, + { status: 404 } + ) + } + + return NextResponse.json({ + success: true, + projectId, + }) + + } catch (error) { + console.error("❌ 프로젝트 ID 조회 오류:", error) + return NextResponse.json( + { + success: false, + message: error instanceof Error ? error.message : "조회 중 오류 발생" + }, + { status: 500 } + ) + } +} + diff --git a/lib/cover/cover-service.ts b/lib/cover/cover-service.ts new file mode 100644 index 00000000..eecc289b --- /dev/null +++ b/lib/cover/cover-service.ts @@ -0,0 +1,213 @@ +// lib/cover/cover-service.ts +import { PDFNet } from "@pdftron/pdfnet-node" +import { promises as fs } from "fs" +import path from "path" +import { file as tmpFile } from "tmp-promise" +import db from "@/db/db" +import { projectCoverTemplates, projects } from "@/db/schema" +import { eq, and } from "drizzle-orm" + +/** + * 프로젝트 정보 조회 + */ +async function getProjectInfo(projectId: number) { + const [project] = await db + .select({ + id: projects.id, + code: projects.code, + name: projects.name, + }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1) + + return project || null +} + +/** + * 활성 커버페이지 템플릿 조회 + */ +async function getActiveTemplate(projectId: number) { + const [template] = await db + .select() + .from(projectCoverTemplates) + .where( + and( + eq(projectCoverTemplates.projectId, projectId), + eq(projectCoverTemplates.isActive, true) + ) + ) + .limit(1) + + return template || null +} + +/** + * 웹 경로를 실제 파일 시스템 경로로 변환 + * 환경변수 NAS_PATH를 기준으로 통일 + */ +function convertWebPathToFilePath(webPath: string): string { + // 환경변수 기준 (개발: public, 프로덕션: NAS) + const storagePath = process.env.NAS_PATH || path.join(process.cwd(), "public") + + // /api/files/... 형태인 경우 처리 + if (webPath.startsWith("/api/files/")) { + const requestedPath = webPath.substring("/api/files/".length) + return path.join(storagePath, requestedPath) + } + + // /uploads/... 형태 + if (webPath.startsWith("/uploads/")) { + return path.join(storagePath, webPath.substring(1)) + } + + // /archive/... 형태 + if (webPath.startsWith("/archive/")) { + return path.join(storagePath, webPath.substring(1)) + } + + // 이미 절대 경로인 경우 그대로 반환 + if (path.isAbsolute(webPath)) { + return webPath + } + + // 상대 경로인 경우 + return path.join(storagePath, webPath) +} + +/** + * 커버페이지 생성 (템플릿 변수 치환) + * + * @param projectId - 프로젝트 ID + * @param docNumber - 문서 번호 + * @returns 생성된 DOCX 파일 버퍼 + */ +export async function generateCoverPage( + projectId: number, + docNumber: string +): Promise<{ + success: boolean + buffer?: Buffer + fileName?: string + error?: string +}> { + try { + console.log(`📄 커버페이지 생성 시작 - ProjectID: ${projectId}, DocNumber: ${docNumber}`) + + // 1. 프로젝트 정보 조회 + const project = await getProjectInfo(projectId) + if (!project) { + return { + success: false, + error: "프로젝트를 찾을 수 없습니다" + } + } + + console.log(`✅ 프로젝트 정보 조회 완료:`, project) + + // 2. 활성 템플릿 조회 + const template = await getActiveTemplate(projectId) + if (!template) { + return { + success: false, + error: "활성 커버페이지 템플릿을 찾을 수 없습니다" + } + } + + console.log(`✅ 템플릿 조회 완료:`, template.templateName) + + // 3. 템플릿 파일 경로 변환 + console.log(`🔄 웹 경로: ${template.filePath}`) + const templateFilePath = convertWebPathToFilePath(template.filePath) + console.log(`📂 실제 파일 경로: ${templateFilePath}`) + console.log(`💾 파일 저장소 경로: ${process.env.NAS_PATH || path.join(process.cwd(), "public")}`) + + // 4. 템플릿 파일 존재 확인 + try { + await fs.access(templateFilePath) + console.log(`✅ 템플릿 파일 존재 확인 완료`) + } catch { + console.error(`❌ 템플릿 파일을 찾을 수 없음: ${templateFilePath}`) + return { + success: false, + error: `템플릿 파일을 찾을 수 없습니다: ${template.filePath}` + } + } + + // 5. 템플릿 변수 데이터 준비 + const templateData = { + docNumber: docNumber, + projectNumber: project.code, + projectName: project.name, + } + + console.log(`🔄 템플릿 변수:`, templateData) + + // 6. PDFTron으로 변수 치환 (서버 사이드) + const result = await PDFNet.runWithCleanup(async () => { + console.log("🔄 PDFTron 초기화 및 변수 치환 시작") + + // 임시 파일 생성 (결과물 저장용) + const { path: tempOutputPath, cleanup } = await tmpFile({ + postfix: ".docx", + }) + + try { + // 템플릿 로드 및 변수 치환 + console.log("📄 템플릿 로드 중...") + const templateDoc = await PDFNet.Convert.createOfficeTemplateWithPath( + templateFilePath + ) + + console.log("🔄 변수 치환 중...") + // JSON 형태로 변수 전달하여 치환 + const resultDoc = await templateDoc.fillTemplateJson( + JSON.stringify(templateData) + ) + + console.log("💾 결과 파일 저장 중...") + // 임시 파일로 저장 + await resultDoc.save(tempOutputPath, PDFNet.SDFDoc.SaveOptions.e_linearized) + + console.log("✅ 변수 치환 완료") + + // 파일 읽기 + const buffer = await fs.readFile(tempOutputPath) + + return { + success: true, + buffer: Buffer.from(buffer), + } + } finally { + // 임시 파일 정리 + await cleanup() + } + }, process.env.NEXT_PUBLIC_PDFTRON_SERVER_KEY) + + if (!result.success || !result.buffer) { + return { + success: false, + error: "커버페이지 생성 중 오류가 발생했습니다" + } + } + + // 7. 파일명 생성 + const fileName = `${docNumber}_cover.docx` + + console.log(`✅ 커버페이지 생성 완료: ${fileName}`) + + return { + success: true, + buffer: result.buffer, + fileName, + } + + } catch (error) { + console.error("❌ 커버페이지 생성 오류:", error) + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류" + } + } +} + diff --git a/lib/swp/project-utils.ts b/lib/swp/project-utils.ts new file mode 100644 index 00000000..682c8da0 --- /dev/null +++ b/lib/swp/project-utils.ts @@ -0,0 +1,47 @@ +// lib/swp/project-utils.ts +import db from "@/db/db" +import { projects } from "@/db/schema" +import { eq } from "drizzle-orm" + +/** + * 프로젝트 코드(PROJ_NO)로 프로젝트 ID 조회 + * + * @param projectCode - 프로젝트 코드 (PROJ_NO) + * @returns 프로젝트 ID 또는 null + */ +export async function getProjectIdByCode(projectCode: string): Promise<number | null> { + try { + const [project] = await db + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1) + + return project?.id || null + } catch (error) { + console.error(`❌ 프로젝트 ID 조회 실패 (코드: ${projectCode}):`, error) + return null + } +} + +/** + * 프로젝트 코드로 프로젝트 전체 정보 조회 + * + * @param projectCode - 프로젝트 코드 (PROJ_NO) + * @returns 프로젝트 정보 또는 null + */ +export async function getProjectByCode(projectCode: string) { + try { + const [project] = await db + .select() + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1) + + return project || null + } catch (error) { + console.error(`❌ 프로젝트 정보 조회 실패 (코드: ${projectCode}):`, error) + return null + } +} + diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx index 5334bd8c..9fb85d2a 100644 --- a/lib/swp/table/swp-table-columns.tsx +++ b/lib/swp/table/swp-table-columns.tsx @@ -1,10 +1,12 @@ "use client"; +import React from "react"; import { ColumnDef } from "@tanstack/react-table"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Download } from "lucide-react"; +import { Download, Loader2 } from "lucide-react"; import type { DocumentListItem } from "@/lib/swp/document-service"; +import { toast } from "sonner"; export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [ { @@ -130,24 +132,87 @@ export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [ { id: "actions", header: "액션", - cell: ({ row }) => ( - <Button - variant="ghost" - size="sm" - onClick={(e) => { - e.stopPropagation(); // 행 클릭 이벤트 방지 - // 커버페이지 다운로드 핸들러는 부모 컴포넌트에서 제공 - const event = new CustomEvent('coverDownload', { - detail: { document: row.original } - }); - window.dispatchEvent(event); - }} - className="h-8 px-2" - > - <Download className="h-4 w-4 mr-1" /> - 커버페이지 - </Button> - ), - size: 120, + cell: function ActionCell({ row }) { + const [isDownloading, setIsDownloading] = React.useState(false); + + const handleDownloadCover = async (e: React.MouseEvent) => { + e.stopPropagation(); // 행 클릭 이벤트 방지 + + const docNumber = row.original.DOC_NO; + const projectCode = row.original.PROJ_NO; + + if (!docNumber || !projectCode) { + toast.error("문서 번호 또는 프로젝트 정보가 없습니다."); + return; + } + + setIsDownloading(true); + + try { + // 1. 프로젝트 코드로 프로젝트 ID 조회 + const projectIdResponse = await fetch( + `/api/projects/code-to-id?code=${encodeURIComponent(projectCode)}` + ); + + if (!projectIdResponse.ok) { + toast.error("프로젝트 정보를 찾을 수 없습니다."); + return; + } + + const { projectId } = await projectIdResponse.json(); + + // 2. 커버페이지 다운로드 API 호출 + const response = await fetch( + `/api/projects/${projectId}/cover?docNumber=${encodeURIComponent(docNumber)}` + ); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || "커버페이지 다운로드 실패"); + } + + // 3. 파일 다운로드 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${docNumber}_cover.docx`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast.success("커버페이지 다운로드가 시작되었습니다."); + + } catch (error) { + console.error("커버페이지 다운로드 오류:", error); + toast.error( + error instanceof Error + ? error.message + : "커버페이지 다운로드 중 오류가 발생했습니다." + ); + } finally { + setIsDownloading(false); + } + }; + + return ( + <Button + variant="ghost" + size="sm" + onClick={handleDownloadCover} + disabled={isDownloading} + className="h-8 w-8 p-0" + title="커버페이지 다운로드" + > + {isDownloading ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + ); + }, + size: 80, }, ]; diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx index 4d824f77..21a1c775 100644 --- a/lib/swp/table/swp-table.tsx +++ b/lib/swp/table/swp-table.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { useReactTable, getCoreRowModel, @@ -17,15 +17,12 @@ import { import { swpDocumentColumns } from "./swp-table-columns"; import { SwpDocumentDetailDialog } from "./swp-document-detail-dialog"; import type { DocumentListItem } from "@/lib/swp/document-service"; -import { toast } from "sonner"; -import { quickDownload } from "@/lib/file-download"; interface SwpTableProps { documents: DocumentListItem[]; projNo: string; vendorCode: string; userId: string; - onCoverDownload?: (document: DocumentListItem) => void; } export function SwpTable({ @@ -33,7 +30,6 @@ export function SwpTable({ projNo, vendorCode, userId, - onCoverDownload, }: SwpTableProps) { const [dialogOpen, setDialogOpen] = useState(false); const [selectedDocument, setSelectedDocument] = useState<DocumentListItem | null>(null); @@ -50,22 +46,6 @@ export function SwpTable({ setDialogOpen(true); }; - // 커버페이지 다운로드 이벤트 리스너 - useEffect(() => { - const handleCoverDownload = (event: CustomEvent) => { - const { document } = event.detail; - if (onCoverDownload) { - onCoverDownload(document); - } - }; - - window.addEventListener('coverDownload', handleCoverDownload as EventListener); - - return () => { - window.removeEventListener('coverDownload', handleCoverDownload as EventListener); - }; - }, [onCoverDownload]); - return ( <div className="space-y-4"> {/* 테이블 */} diff --git a/next.config.ts b/next.config.ts index 44275452..98c58d2e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - serverExternalPackages: ['pino', 'pino-pretty', 'node-cron', 'oracledb','sharp'], + serverExternalPackages: ['pino', 'pino-pretty', 'node-cron', 'oracledb','sharp', '@pdftron/pdfnet-node'], reactStrictMode: false, eslint: { |
