summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-03 18:46:35 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-03 18:46:35 +0900
commit1393acc4b6675fd5eac65c6f1a9e399edfb2d44f (patch)
tree6610385198545277ed51c4616d315aa0800c07bc
parenta9c038e51f1cf508165e9d196ffe332f6ac54d74 (diff)
(김준회) SWP: 커버페이지 생성 API 오류 수정
-rw-r--r--app/[lng]/partners/(partners)/swp-document-upload/vendor-document-page.tsx29
-rw-r--r--app/api/projects/[projectId]/cover/route.ts78
-rw-r--r--app/api/projects/code-to-id/route.ts47
-rw-r--r--lib/cover/cover-service.ts213
-rw-r--r--lib/swp/project-utils.ts47
-rw-r--r--lib/swp/table/swp-table-columns.tsx105
-rw-r--r--lib/swp/table/swp-table.tsx22
-rw-r--r--next.config.ts2
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: {